PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-premium-analytics / src / Sync / class-woocommerce-analytics-module.php
jetpack / jetpack_vendor / automattic / jetpack-premium-analytics / src / Sync Last commit date
class-configuration.php 2 weeks ago class-sync-status-tracker.php 2 weeks ago class-woocommerce-analytics-module.php 2 weeks ago trait-utilities.php 2 weeks ago
class-woocommerce-analytics-module.php
1133 lines
1 <?php
2 /**
3 * TEMPORARY: interim port for WOOA7S-1550 — remove when the shared sync-modules composer package lands.
4 *
5 * Ported (near-verbatim) from woocommerce-analytics'
6 * src/Internal/Jetpack/Sync/Modules/Analytics.php so the monorepo package can register the
7 * `woocommerce_analytics` full-sync module and unblock end-to-end sync testing for the
8 * connection flow (WOOA7S-1549). name() intentionally stays 'woocommerce_analytics' — the JS
9 * site-sync and {@see Sync_Status_Tracker} both target the module by that exact key.
10 *
11 * WooCommerce is a runtime (not composer) dependency. The WC classes/traits referenced here
12 * resolve via WooCommerce's autoloader at runtime; registration is guarded so this class is only
13 * instantiated when WooCommerce is active (see {@see Configuration::register()}).
14 *
15 * @package automattic/jetpack-premium-analytics
16 */
17
18 namespace Automattic\Jetpack\PremiumAnalytics\Sync;
19
20 use Automattic\Jetpack\Sync\Modules\Module as JetpackSyncModule;
21 use Automattic\Jetpack\Sync\Modules\WooCommerce_HPOS_Orders;
22 use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
23 use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrderStatsDataStore;
24 use Automattic\WooCommerce\Enums\OrderInternalStatus;
25 use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;
26 use Automattic\WooCommerce\Internal\Traits\OrderAttributionMeta;
27 use Automattic\WooCommerce\Utilities\FeaturesUtil;
28 use Automattic\WooCommerce\Utilities\OrderUtil;
29 use WC_Abstract_Order;
30 use WC_Coupon;
31 use WC_Order;
32 use WC_Order_Factory;
33 use WC_Tax;
34
35 defined( 'ABSPATH' ) || exit;
36
37 /**
38 * WooCommerce Analytics Module class.
39 */
40 class WooCommerce_Analytics_Module extends JetpackSyncModule {
41
42 use Utilities;
43 // @phan-suppress-next-line PhanUndeclaredTrait -- Provided by WooCommerce at runtime; absent from the older WooCommerce stubs used by the "old Woo" Phan job.
44 use OrderAttributionMeta;
45
46 /**
47 * Get the module name.
48 *
49 * @return string
50 */
51 public function name() {
52 return 'woocommerce_analytics';
53 }
54
55 /**
56 * Get the ID field for the module.
57 *
58 * @return string
59 */
60 public function id_field() {
61 return 'order_id';
62 }
63
64 /**
65 * Get the table in the database.
66 *
67 * @return string
68 */
69 public function table() {
70 global $wpdb;
71 return $wpdb->prefix . 'wc_order_stats';
72 }
73
74 /**
75 * Init listeners.
76 *
77 * @param callable $handler Action handler callable.
78 *
79 * @return void
80 */
81 public function init_listeners( $handler ) {
82 // Actions to update order stats.
83 add_action( 'woocommerce_analytics_delete_order_stats', array( $this, 'sync_deleted_analytics_data' ) );
84
85 // In WooCommerce 10.3+ the new action is available.
86 if ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '10.3', '>=' ) ) {
87 add_action( 'woocommerce_order_scheduler_after_import_order', array( $this, 'sync_analytics_reports_data' ) );
88 } else {
89 add_action( 'woocommerce_analytics_update_order_stats', array( $this, 'sync_analytics_reports_data' ) );
90 }
91
92 // Sync actions.
93 add_action( 'woocommerce_analytics_sync_reports_data', $handler );
94 add_action( 'woocommerce_analytics_delete_reports_data', $handler );
95
96 // Expand data.
97 add_filter( 'jetpack_sync_before_enqueue_woocommerce_analytics_sync_reports_data', array( $this, 'expand_data' ) );
98 add_filter( 'jetpack_sync_before_enqueue_woocommerce_analytics_delete_reports_data', array( $this, 'expand_data' ) );
99 }
100
101 /**
102 * Expand order stats data and attribution data.
103 *
104 * @param array $args List of arguments.
105 *
106 * @return array|false
107 */
108 public function expand_data( $args ) {
109 if ( ! is_array( $args ) || ! isset( $args[0] ) ) {
110 return false;
111 }
112
113 $data = $args[0];
114
115 return $data;
116 }
117
118 /**
119 * Init full sync listeners.
120 *
121 * @param callable $handler Action handler callable.
122 *
123 * @return void
124 */
125 public function init_full_sync_listeners( $handler ) {
126 add_action( 'jetpack_full_sync_woocommerce_analytics', $handler );
127 }
128
129 /**
130 * Get full sync actions.
131 *
132 * @return string[] The full sync actions.
133 */
134 public function get_full_sync_actions() {
135 return array( 'jetpack_full_sync_woocommerce_analytics' );
136 }
137
138 /**
139 * Get the supported object types.
140 *
141 * @return array The supported object types.
142 */
143 private function get_supported_object_types() {
144 return array( 'order', 'order_tax_lookup', 'order_product_lookup', 'order_coupon_lookup' );
145 }
146
147 /**
148 * Retrieves multiple orders data by their ID.
149 *
150 * @param string $object_type Type of object to retrieve. Should be `order`.
151 * @param array $ids List of order IDs.
152 *
153 * @return array
154 */
155 public function get_objects_by_id( $object_type, $ids ) {
156 if ( empty( $ids ) || ! is_array( $ids ) || empty( $object_type ) ) {
157 return array();
158 }
159
160 if ( ! in_array( $object_type, $this->get_supported_object_types(), true ) ) {
161 return array();
162 }
163
164 $orders = wc_get_orders(
165 array(
166 'post__in' => $ids,
167 'post_status' => WooCommerce_HPOS_Orders::get_all_possible_order_status_keys(),
168 'limit' => -1,
169 'orderby' => 'id',
170 'order' => 'DESC',
171 )
172 );
173
174 // Get the order stats data for the orders.
175 $order_stats_items = $this->get_order_stats_items( $ids );
176 $order_stats_data = array();
177 if ( ! empty( $order_stats_items ) ) {
178 $order_stats_data = array_column( $order_stats_items, null, 'order_id' );
179 }
180
181 $orders_data = array();
182 $found_order_ids = array();
183 foreach ( $orders as $order ) {
184 $order_id = $order->get_id();
185 $found_order_ids[] = $order_id;
186 $orders_data[ $order_id ] = $this->build_woocommerce_analytics_reports_data( $order );
187 if ( 'order' === $object_type ) {
188 // Sync everything if the object type is order.
189 $orders_data[ $order_id ] = $this->build_woocommerce_analytics_reports_data( $order );
190 } else {
191 $orders_data[ $order_id ] = $this->build_woocommerce_analytics_reports_lookup_data( $order, $object_type );
192 }
193 if ( isset( $order_stats_data[ $order_id ] ) ) {
194 $this->do_order_status_discrepancy_check( $order, $order_stats_data[ $order_id ] );
195 }
196 }
197
198 // Check for missing order_ids in wc_order_stats table for orders that were not found.
199 $missing_order_ids = array_diff( $ids, $found_order_ids );
200
201 /**
202 * Trigger missing orders detected action.
203 *
204 * @param array $missing_order_ids The missing order IDs.
205 */
206 do_action( 'woocommerce_analytics_missing_orders_detected', $missing_order_ids );
207
208 foreach ( $missing_order_ids as $missing_order_id ) {
209 if ( 'order' === $object_type ) {
210 $orders_data[ $missing_order_id ] = $this->build_woocommerce_analytics_reports_data( $missing_order_id );
211 } else {
212 $orders_data[ $missing_order_id ] = $this->build_woocommerce_analytics_reports_lookup_data( $missing_order_id, $object_type );
213 }
214 }
215 // Let's sort the orders by ID in descending order. This is useful for the full sync to ensure that the latest orders are processed first.
216 krsort( $orders_data, SORT_NUMERIC );
217 return $orders_data;
218 }
219
220 /**
221 * Retrieve the analytics order data by its ID.
222 *
223 * @param string $object_type Type of the sync object.
224 * @param int $id ID of the sync object.
225 * @return mixed Object, or false if the object is invalid.
226 */
227 public function get_object_by_id( $object_type, $id ) {
228 if ( ! in_array( $object_type, $this->get_supported_object_types(), true ) ) {
229 return false;
230 }
231
232 $order = wc_get_order( $id );
233
234 if ( ! $order instanceof WC_Abstract_Order ) {
235 $order = $id; // If the order does not exists. We'll check if the order_id exists in wc_order_stats table.
236 }
237
238 if ( 'order' === $object_type ) {
239 return $this->build_woocommerce_analytics_reports_data( $order );
240 }
241
242 return $this->build_woocommerce_analytics_reports_lookup_data( $order, $object_type );
243 }
244
245 /**
246 * Enqueue full sync actions.
247 *
248 * @param array $config Full sync configuration.
249 * @param int $max_items_to_enqueue Maximum number of items to enqueue.
250 * @param boolean $state True if full sync has finished enqueueing this module.
251 * @return array Number of actions enqueued, and next module state.
252 */
253 public function enqueue_full_sync_actions( $config, $max_items_to_enqueue, $state ) {
254 return $this->enqueue_all_ids_as_action(
255 'jetpack_full_sync_woocommerce_analytics',
256 $this->table(),
257 $this->id_field(),
258 $this->get_where_sql( $config ),
259 $max_items_to_enqueue,
260 $state
261 );
262 }
263
264 /**
265 * Estimate full sync actions.
266 *
267 * @param array $config Full sync configuration.
268 * @return int Number of items yet to be enqueued.
269 */
270 public function estimate_full_sync_actions( $config ) {
271 global $wpdb;
272
273 $query = "SELECT COUNT(*) FROM {$this->table()}";
274
275 $where_sql = $this->get_where_sql( $config );
276 if ( $where_sql ) {
277 $query .= ' WHERE ' . $where_sql;
278 }
279
280 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
281 $count = (int) $wpdb->get_var( $query );
282
283 return (int) ceil( $count / self::ARRAY_CHUNK_SIZE );
284 }
285
286 /**
287 * Get where SQL clause for the module.
288 *
289 * @param array $config Full sync configuration.
290 * @return string
291 */
292 public function get_where_sql( $config ) {
293 global $wpdb;
294
295 $where = '1=1';
296
297 if ( ! empty( $config['start_date'] ) ) {
298 $where .= $wpdb->prepare( ' AND date_created >= %s', $config['start_date'] );
299 }
300 if ( ! empty( $config['end_date'] ) ) {
301 $where .= $wpdb->prepare( ' AND date_created <= %s', $config['end_date'] );
302 }
303
304 /**
305 * Filter the WHERE SQL for analytics full sync
306 *
307 * @param string $where The WHERE SQL clause
308 * @param array $config The sync configuration
309 */
310 return apply_filters( 'woocommerce_analytics_full_sync_where_sql', $where, $config );
311 }
312
313 /**
314 * Initialize module in the sender.
315 */
316 public function init_before_send() {
317 // Full sync.
318 add_filter(
319 'jetpack_sync_before_send_jetpack_full_sync_woocommerce_analytics',
320 array( $this, 'build_full_sync_action_array' )
321 );
322 }
323
324 /**
325 * Build the full sync action object.
326 *
327 * @param array $args An array with filtered objects and previous end.
328 *
329 * @return array An array with orders and previous end.
330 */
331 public function build_full_sync_action_array( $args ) {
332 list( $filtered_orders, $previous_end ) = $args;
333 return array(
334 'orders' => $filtered_orders['objects'],
335 'previous_end' => $previous_end,
336 );
337 }
338
339 /**
340 * Given the Module Configuration and Status return the next chunk of items to send.
341 * This function also expands the posts and metadata and filters them based on the maximum size constraints.
342 *
343 * @param array $config This module Full Sync configuration.
344 * @param array $status This module Full Sync status.
345 * @param int $chunk_size Chunk size.
346 *
347 * @return array
348 */
349 public function get_next_chunk( $config, $status, $chunk_size ) {
350
351 $order_ids = parent::get_next_chunk( $config, $status, $chunk_size );
352
353 if ( empty( $order_ids ) ) {
354 return array();
355 }
356
357 $orders = $this->get_objects_by_id( 'order', $order_ids );
358
359 // If no orders were fetched, make sure to return the expected structure so that status is updated correctly.
360 if ( empty( $orders ) ) {
361 return array(
362 'object_ids' => $order_ids,
363 'objects' => array(),
364 );
365 }
366
367 // Filter the orders based on the maximum size constraints.
368 list( $filtered_order_ids, $filtered_orders, ) = $this->filter_analytics_objects_by_size( $orders );
369
370 return array(
371 'object_ids' => $filtered_order_ids,
372 'objects' => $filtered_orders,
373 );
374 }
375
376 /**
377 * Filters objects and metadata based on maximum size constraints.
378 * It always allows the first object with its metadata, even if they exceed the limit.
379 *
380 * @param array $objects The array of objects to filter.
381 *
382 * @return array An array containing the filtered object IDsand filtered objects
383 */
384 public function filter_analytics_objects_by_size( $objects ) {
385 $filtered_objects = array();
386 $filtered_object_ids = array();
387 $current_size = 0;
388
389 foreach ( $objects as $key => $value ) {
390 $object_size = strlen( maybe_serialize( $value ) );
391
392 // Always allow the first object.
393 if ( empty( $filtered_object_ids ) || ( $current_size + $object_size ) <= self::MAX_SIZE_FULL_SYNC ) {
394 $filtered_object_ids[] = $key;
395 $filtered_objects[ $key ] = $value;
396 $current_size += $object_size;
397 } else {
398 break;
399 }
400 }
401
402 return array(
403 $filtered_object_ids,
404 $filtered_objects,
405 );
406 }
407
408 /**
409 * Handle Sync analytics reports data.
410 *
411 * @param int $order_id The order ID.
412 * @return void
413 */
414 public function sync_analytics_reports_data( $order_id ) {
415
416 $data = $this->get_object_by_id( 'order', $order_id );
417
418 if ( ! $data ) {
419 return;
420 }
421
422 /**
423 * Trigger the action to sync the reports data.
424 *
425 * @param array $data Analytics reports sync data.
426 */
427 do_action( 'woocommerce_analytics_sync_reports_data', $data );
428 }
429
430 /**
431 * Handle syncing of analytics deletion data.
432 *
433 * @param int $order_id The order ID.
434 * @return void
435 */
436 public function sync_deleted_analytics_data( $order_id ) {
437 if ( empty( $order_id ) ) {
438 return;
439 }
440
441 $data = array(
442 'id' => $order_id,
443 );
444
445 /**
446 * Filter the deletion data before syncing.
447 *
448 * @param array $data The deletion data.
449 * @param WC_Order $order The order object.
450 */
451 $data = apply_filters( 'woocommerce_analytics_deletion_data', $data );
452
453 /**
454 * Trigger the action to sync the deletion.
455 *
456 * @param array $data The deletion sync data.
457 */
458 do_action( 'woocommerce_analytics_delete_reports_data', $data );
459 }
460
461 /**
462 * Build the WooCommerce analytics reports data.
463 *
464 * @param mixed $order The order ID or the WC_Order object.
465 * @return array The reports data.
466 */
467 protected function build_woocommerce_analytics_reports_data( $order ) {
468 $data_types = array(
469 'order_stats' => $this->get_order_stats_data( $order ),
470 'order_attribution_data' => $this->get_order_attribution_data( $order ),
471 'order_product_data' => $this->get_order_product_data( $order ),
472 'order_coupon_data' => $this->get_order_coupon_data( $order ),
473 'order_tax_data' => $this->get_order_tax_data( $order ),
474 );
475
476 $reports_data = array_filter( $data_types );
477
478 /**
479 * Filter the reports data before syncing.
480 *
481 * @param array $data The reports data.
482 * @param WC_Order $order The order object.
483 */
484 return apply_filters( 'woocommerce_analytics_reports_data', $reports_data, $order );
485 }
486
487 /**
488 * Build the WooCommerce analytics reports data for lookup tables.
489 *
490 * @param mixed $order The order ID or the WC_Order object.
491 * @param string $object_type The object type.
492 * @return array The reports data.
493 */
494 protected function build_woocommerce_analytics_reports_lookup_data( $order, $object_type ) {
495 $report_data = array();
496 switch ( $object_type ) {
497 case 'order_product_lookup':
498 $report_data['order_product_data'] = $this->get_order_product_data( $order );
499 break;
500 case 'order_coupon_lookup':
501 $report_data['order_coupon_data'] = $this->get_order_coupon_data( $order );
502 break;
503 case 'order_tax_lookup':
504 $report_data['order_tax_data'] = $this->get_order_tax_data( $order );
505 break;
506 }
507
508 /**
509 * Filter the reports lookup data before syncing.
510 *
511 * @param array $data The reports lookup data.
512 * @param WC_Order $order The order object.
513 * @param string $object_type The object type.
514 */
515 return apply_filters( 'woocommerce_analytics_reports_lookup_data', $report_data, $order, $object_type );
516 }
517
518 /**
519 * Get order attribution data.
520 *
521 * @param mixed $order The order ID or the WC_Order object.
522 * @return array|bool The order attribution data or false if the order is invalid.
523 */
524 protected function get_order_attribution_data( $order ) {
525 if ( is_numeric( $order ) ) {
526 $order = wc_get_order( $order );
527 }
528
529 if ( ! $order ) {
530 return false;
531 }
532
533 $this->set_fields_and_prefix();
534 $order_id = $order->get_id();
535 $type = $order->get_type();
536 $allowed_keys = array(
537 'utm_campaign',
538 'utm_source',
539 'utm_medium',
540 'utm_content',
541 'utm_term',
542 'utm_source_platform',
543 'origin',
544 'device_type',
545 'source_type',
546 );
547
548 if ( 'shop_order_refund' === $type && ! empty( $order->get_parent_id() ) ) {
549 $order_object_to_use = wc_get_order( $order->get_parent_id() );
550 } else {
551 $order_object_to_use = $order;
552 }
553
554 $attribution_data = array(
555 'order_id' => $order_id,
556 );
557
558 foreach ( $allowed_keys as $key ) {
559 $meta_key = $this->get_meta_prefixed_field_name( $key );
560 $attribution_data[ $key ] = $order_object_to_use->get_meta( $meta_key, true );
561 }
562
563 return $attribution_data;
564 }
565
566 /**
567 * Handler order stats update.
568 *
569 * @param mixed $order The order ID or the WC_Order object.
570 * @return array|bool The order attribution data or false if the order stats item does not exist.
571 */
572 protected function get_order_stats_data( $order ) {
573 if ( is_numeric( $order ) ) {
574 $order_id = $order;
575 $order = wc_get_order( $order );
576 } elseif ( $order instanceof WC_Abstract_Order ) {
577 $order_id = $order->get_id();
578 } else {
579 return false;
580 }
581
582 // If the order does not exit, check if the stats item is present in the wc_order_stats table.
583 if ( ! $order ) {
584 $order_stats_data_from_db = $this->get_order_stats_data_from_db( $order_id );
585 return $order_stats_data_from_db;
586 }
587
588 $order_fulfillment_status = null;
589 // @phan-suppress-next-line PhanUndeclaredStaticMethod -- Guarded by is_callable(); absent from the older WooCommerce stubs used by the "old Woo" Phan job.
590 if ( is_callable( array( OrderStatsDataStore::class, 'has_fulfillment_status_column' ) ) && OrderStatsDataStore::has_fulfillment_status_column() ) {
591 $order_stats_item = $this->get_order_stats_item( $order->get_id() );
592 $order_fulfillment_status = $order_stats_item['fulfillment_status'] ?? null;
593 } elseif ( is_callable( array( FulfillmentUtils::class, 'get_order_fulfillment_status' ) ) && $order instanceof WC_Order ) {
594 $fulfillment_status = FulfillmentUtils::get_order_fulfillment_status( $order );
595 $order_fulfillment_status = 'no_fulfillments' !== $fulfillment_status ? $fulfillment_status : null;
596 }
597
598 $order_stats_data = array(
599 'order_id' => $order->get_id(),
600 'parent_id' => $order->get_parent_id(),
601 'date_created' => self::datetime_to_object( $order->get_date_created() ),
602 'date_paid' => self::datetime_to_object( $order->get_date_paid() ),
603 'date_completed' => self::datetime_to_object( $order->get_date_completed() ),
604 'num_items_sold' => self::get_num_items_sold( $order ),
605 'total_sales' => $order->get_total(),
606 'tax_total' => $order->get_total_tax(),
607 'total_fees' => $order->get_total_fees(),
608 'total_fees_tax' => self::get_total_fees_tax( $order ),
609 'shipping_total' => $order->get_shipping_total(),
610 'shipping_tax' => $order->get_shipping_tax(),
611 'discount_total' => $order->get_discount_total(),
612 'discount_tax' => $order->get_discount_tax(),
613 'net_total' => self::get_net_total( $order ),
614 'returning_customer' => $order->is_returning_customer(),
615 'status' => self::normalize_order_status( $order->get_status() ),
616 'customer_id' => $order->get_report_customer_id(),
617 'fulfillment_status' => $order_fulfillment_status,
618 );
619
620 if ( 'shop_order_refund' === $order->get_type() ) {
621 $parent_order = wc_get_order( $order->get_parent_id() );
622 if ( $parent_order ) {
623 $order_stats_data['parent_id'] = $parent_order->get_id();
624
625 $refund_type = $order->get_meta( '_refund_type' );
626 // @phan-suppress-next-line PhanUndeclaredStaticMethod -- Absent from the older WooCommerce stubs used by the "old Woo" Phan job.
627 if ( 'full' === $refund_type && OrderUtil::uses_new_full_refund_data() ) {
628 $order_stats_data['tax_total'] = -1 * $parent_order->get_total_tax();
629 $order_stats_data['num_items_sold'] = -1 * self::get_num_items_sold( $parent_order );
630 $order_stats_data['net_total'] = -1 * self::get_net_total( $parent_order );
631 $order_stats_data['shipping_total'] = -1 * (float) $parent_order->get_shipping_total();
632 }
633 }
634 /**
635 * Set date_completed and date_paid the same as date_created to avoid problems
636 * when they are being used to sort the data, as refunds don't have them filled
637 */
638 $date_created_gmt = self::datetime_to_object( $order->get_date_created() );
639 $order_stats_data['date_completed'] = $date_created_gmt;
640 $order_stats_data['date_paid'] = $date_created_gmt;
641 }
642
643 return $order_stats_data;
644 }
645
646 /**
647 * Calculation methods.
648 */
649
650 /**
651 * Get number of items sold among all orders.
652 *
653 * @param WC_Order $order WC_Order object.
654 * @return int
655 */
656 protected static function get_num_items_sold( $order ) {
657 $num_items = 0;
658
659 $line_items = $order->get_items( 'line_item' );
660 foreach ( $line_items as $line_item ) {
661 $num_items += $line_item->get_quantity();
662 }
663
664 return $num_items;
665 }
666
667 /**
668 * Get the net amount from an order without shipping, tax, or refunds.
669 *
670 * @param WC_Order $order WC_Order object.
671 * @return float
672 */
673 protected static function get_net_total( $order ) {
674 $net_total = floatval( $order->get_total() ) - floatval( $order->get_total_tax() ) - floatval( $order->get_shipping_total() );
675 return $net_total;
676 }
677
678 /**
679 * Get the total fees tax from an order.
680 *
681 * @param WC_Order $order WC_Order object.
682 * @return float
683 */
684 protected static function get_total_fees_tax( $order ) {
685 $total_fees_tax = array_sum(
686 array_map(
687 function ( $item ) {
688 return $item->get_total_tax();
689 },
690 array_values( $order->get_items( 'fee' ) )
691 )
692 );
693
694 return $total_fees_tax;
695 }
696
697 /**
698 * Get the order stats row for a given order ID.
699 *
700 * @param int $order_id The order ID.
701 * @return array|null|void Database query result in format specified by $output or null on failure.
702 */
703 private function get_order_stats_item( $order_id ) {
704 global $wpdb;
705
706 $query = $wpdb->prepare(
707 "SELECT * FROM {$this->table()} WHERE order_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
708 $order_id
709 );
710
711 return $wpdb->get_row( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
712 }
713
714 /**
715 * Get the order stats rows for a given order IDs.
716 *
717 * @param array $order_ids The order IDs.
718 * @return array|null Database query result in format specified by $output or null on failure.
719 */
720 private function get_order_stats_items( $order_ids ) {
721 global $wpdb;
722
723 $placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );
724 $query = $wpdb->prepare(
725 "SELECT * FROM {$this->table()} WHERE order_id IN ( $placeholders )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
726 $order_ids
727 );
728
729 return $wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
730 }
731
732 /**
733 * Check if the COGS feature is enabled.
734 *
735 * @return bool True if the COGS feature is enabled, false otherwise.
736 */
737 private function is_cogs_enabled() {
738 return FeaturesUtil::feature_is_enabled( 'cost_of_goods_sold' );
739 }
740
741 /**
742 * Get order product lookup data.
743 *
744 * @param mixed $order The order ID or the WC_Order object.
745 * @return array|bool The order product data or false if no data exists.
746 */
747 protected function get_order_product_data( $order ) {
748 if ( is_numeric( $order ) ) {
749 $order_id = $order;
750 $order = wc_get_order( $order );
751 } elseif ( $order instanceof WC_Abstract_Order ) {
752 $order_id = $order->get_id();
753 } else {
754 return false;
755 }
756
757 // If the order does not exist, check if product lookup data exists in the database.
758 if ( ! $order ) {
759 return $this->get_order_product_data_from_db( $order_id );
760 }
761
762 // Get the order product data from the order object.
763 $order_products = $order->get_items( 'line_item' );
764
765 if ( empty( $order_products ) ) {
766 // Not a common use case, but there could be a case where this returns empty.
767 return $this->get_order_product_data_from_db( $order_id );
768 }
769
770 $is_refund_order = $this->is_refund_order( $order_id );
771 $round_tax = 'no' === get_option( 'woocommerce_tax_round_at_subtotal' );
772 $decimals = wc_get_price_decimals();
773
774 $results = array();
775 foreach ( $order_products as $order_product ) {
776 $shipping_amount = $order->get_item_shipping_amount( $order_product );
777 $shipping_tax_amount = $order->get_item_shipping_tax_amount( $order_product );
778 $coupon_amount = $order->get_item_coupon_amount( $order_product );
779 // Tax amount.
780 $tax_amount = 0;
781 $order_taxes = $order->get_taxes();
782 $tax_data = $order_product->get_taxes();
783 foreach ( $order_taxes as $tax_item ) {
784 $tax_item_id = $tax_item->get_rate_id();
785 $tax_amount += isset( $tax_data['total'][ $tax_item_id ] ) ? (float) $tax_data['total'][ $tax_item_id ] : 0;
786 }
787
788 $net_revenue = round( $order_product->get_total( 'edit' ), $decimals );
789 if ( $round_tax ) {
790 $tax_amount = round( $tax_amount, $decimals );
791 }
792
793 $product_id = $order_product->get_product_id();
794 $cogs_amount = $this->get_order_product_cogs_value( $order_product );
795
796 $product_data = array(
797 'order_id' => $order_id,
798 'order_item_id' => $order_product->get_id(),
799 'product_id' => $product_id,
800 'variation_id' => $order_product->get_variation_id(),
801 'product_qty' => $order_product->get_quantity(),
802 'product_net_revenue' => $net_revenue,
803 'product_gross_revenue' => $net_revenue + $tax_amount + $shipping_amount + $shipping_tax_amount,
804 'shipping_amount' => $shipping_amount,
805 'shipping_tax_amount' => $shipping_tax_amount,
806 'coupon_amount' => $coupon_amount,
807 'tax_amount' => $tax_amount,
808 'customer_id' => $order->get_report_customer_id(),
809 'date_created' => self::datetime_to_object( $order->get_date_created() ),
810 'cogs_amount' => $is_refund_order ? -abs( $cogs_amount ) : $cogs_amount,
811 );
812
813 $results[] = $product_data;
814 }
815
816 return $results;
817 }
818
819 /**
820 * Get COGS value for an order product.
821 *
822 * @param object $order_product The order product object.
823 * @return float|null The COGS amount or null if not available.
824 */
825 private function get_order_product_cogs_value( $order_product ) {
826 if ( ! method_exists( $order_product, 'get_cogs_value' ) || ! $this->is_cogs_enabled() ) {
827 return null;
828 }
829
830 $cogs_amount = $order_product->get_cogs_value();
831
832 // Only fallback to product's COGS value if order product's COGS is null (not set).
833 if ( null === $cogs_amount ) {
834 $product_id = $order_product->get_product_id();
835 $product = wc_get_product( $product_id );
836
837 if ( $product && method_exists( $product, 'get_cogs_value' ) ) {
838 $product_cogs_value = $product->get_cogs_value();
839 if ( null !== $product_cogs_value ) {
840 $cogs_amount = $product_cogs_value;
841 }
842 }
843 }
844
845 return $cogs_amount;
846 }
847
848 /**
849 * Get order product lookup data from database.
850 *
851 * @param int $order_id The order ID.
852 * @return array|bool The order product data or false if no data exists.
853 */
854 protected function get_order_product_data_from_db( $order_id ) {
855 $results = $this->get_order_lookup_data_from_db( 'wc_order_product_lookup', $order_id );
856
857 if ( empty( $results ) ) {
858 return false;
859 }
860
861 $is_refund_order = $this->is_refund_order( $order_id );
862
863 $parsed_results = array();
864 foreach ( $results as $result ) {
865 $order_item = WC_Order_Factory::get_order_item( absint( $result['order_item_id'] ) );
866 $cogs_amount = $this->get_order_product_cogs_value( $order_item );
867
868 $product_data = array(
869 'date_created' => self::datetime_to_object( $result['date_created'] ),
870 'product_net_revenue' => floatval( $result['product_net_revenue'] ),
871 'product_gross_revenue' => floatval( $result['product_gross_revenue'] ),
872 'shipping_amount' => floatval( $result['shipping_amount'] ),
873 'shipping_tax_amount' => floatval( $result['shipping_tax_amount'] ),
874 'product_qty' => intval( $result['product_qty'] ),
875 'variation_id' => intval( $result['variation_id'] ),
876 'product_id' => intval( $result['product_id'] ),
877 'customer_id' => intval( $result['customer_id'] ),
878 'coupon_amount' => floatval( $result['coupon_amount'] ),
879 'tax_amount' => floatval( $result['tax_amount'] ),
880 'order_item_id' => intval( $result['order_item_id'] ),
881 'order_id' => intval( $result['order_id'] ),
882 'cogs_amount' => $is_refund_order ? -abs( $cogs_amount ) : $cogs_amount,
883 );
884
885 $parsed_results[] = $product_data;
886 }
887
888 return $parsed_results;
889 }
890
891 /**
892 * Check if the order is a refund order.
893 *
894 * @param int $order_id The order ID.
895 * @return bool True if the order is a refund order, false otherwise.
896 */
897 private function is_refund_order( $order_id ) {
898 $order_stats_data = $this->get_order_stats_item( $order_id );
899
900 if ( ! $order_stats_data || empty( $order_stats_data['parent_id'] ) ) {
901 return false;
902 }
903
904 $parent_id = $order_stats_data['parent_id'];
905 $parent_order_stats_data = $this->get_order_stats_item( $parent_id );
906
907 if ( ! $parent_order_stats_data || empty( $parent_order_stats_data['status'] ) ) {
908 return false;
909 }
910
911 return OrderInternalStatus::REFUNDED === $parent_order_stats_data['status'];
912 }
913
914 /**
915 * Get order coupon lookup data.
916 *
917 * @param mixed $order The order ID or the WC_Order object.
918 * @return array|bool The order coupon data or false if no data exists.
919 */
920 protected function get_order_coupon_data( $order ) {
921 if ( is_numeric( $order ) ) {
922 $order_id = $order;
923 $order = wc_get_order( $order );
924 } elseif ( $order instanceof WC_Abstract_Order ) {
925 $order_id = $order->get_id();
926 } else {
927 return false;
928 }
929
930 // If the order does not exist, check if coupon lookup data exists in the database.
931 if ( ! $order ) {
932 return $this->get_order_coupon_data_from_db( $order_id );
933 }
934
935 // Get the order coupon data from the order object.
936 $order_coupons = $order->get_coupons();
937
938 $results = array();
939 foreach ( $order_coupons as $coupon ) {
940 $results[] = array(
941 'order_id' => $order_id,
942 'coupon_id' => CouponsDataStore::get_coupon_id( $coupon ),
943 'discount_amount' => $coupon->get_discount(),
944 'date_created' => self::datetime_to_object( $order->get_date_created() ),
945 'coupon_code' => $coupon->get_code(),
946 );
947 }
948
949 return $results;
950 }
951
952 /**
953 * Get order coupon lookup data from database.
954 *
955 * @param int $order_id The order ID.
956 * @return array|bool The order coupon data or false if no data exists.
957 */
958 protected function get_order_coupon_data_from_db( $order_id ) {
959 $results = $this->get_order_lookup_data_from_db( 'wc_order_coupon_lookup', $order_id );
960
961 if ( empty( $results ) ) {
962 return false;
963 }
964
965 $parsed_results = array();
966 foreach ( $results as $result ) {
967 $result_data = array(
968 'date_created' => self::datetime_to_object( $result['date_created'] ),
969 'discount_amount' => floatval( $result['discount_amount'] ),
970 'order_id' => intval( $result['order_id'] ),
971 'coupon_id' => intval( $result['coupon_id'] ),
972 );
973 $coupon = new WC_Coupon( absint( $result['coupon_id'] ) );
974 $result_data['coupon_code'] = $coupon->get_code();
975 $parsed_results[] = $result_data;
976 }
977
978 return $parsed_results;
979 }
980
981 /**
982 * Get order tax lookup data.
983 *
984 * @param mixed $order The order ID or the WC_Order object.
985 * @return array|bool The order tax data or false if no data exists.
986 */
987 protected function get_order_tax_data( $order ) {
988 if ( is_numeric( $order ) ) {
989 $order_id = $order;
990 $order = wc_get_order( $order );
991 } elseif ( $order instanceof WC_Abstract_Order ) {
992 $order_id = $order->get_id();
993 } else {
994 return false;
995 }
996
997 // If the order does not exist, check if tax lookup data exists in the database.
998 if ( ! $order ) {
999 return $this->get_order_tax_data_from_db( $order_id );
1000 }
1001
1002 // Get the order tax data from the order object.
1003 $order_taxes = $order->get_taxes();
1004
1005 $results = array();
1006 foreach ( $order_taxes as $tax ) {
1007 $order_tax = (float) $tax->get_tax_total();
1008 $shipping_tax = (float) $tax->get_shipping_tax_total();
1009 $results[] = array(
1010 'order_id' => $order_id,
1011 'tax_rate_id' => $tax->get_rate_id(),
1012 'order_tax' => $order_tax,
1013 'shipping_tax' => $shipping_tax,
1014 'total_tax' => $order_tax + $shipping_tax,
1015 'date_created' => self::datetime_to_object( $order->get_date_created() ),
1016 'tax_rate_code' => $tax->get_rate_code(),
1017 );
1018 }
1019
1020 return $results;
1021 }
1022
1023 /**
1024 * Get order tax lookup data from database.
1025 *
1026 * @param int $order_id The order ID.
1027 * @return array|bool The order tax data or false if no data exists.
1028 */
1029 protected function get_order_tax_data_from_db( $order_id ) {
1030 $results = $this->get_order_lookup_data_from_db( 'wc_order_tax_lookup', $order_id );
1031
1032 if ( empty( $results ) ) {
1033 return false;
1034 }
1035
1036 $parsed_results = array();
1037 foreach ( $results as $result ) {
1038 $result_data = array(
1039 'date_created' => self::datetime_to_object( $result['date_created'] ),
1040 'order_tax' => floatval( $result['order_tax'] ),
1041 'total_tax' => floatval( $result['total_tax'] ),
1042 'shipping_tax' => floatval( $result['shipping_tax'] ),
1043 'order_id' => intval( $result['order_id'] ),
1044 'tax_rate_id' => intval( $result['tax_rate_id'] ),
1045 'tax_rate_code' => WC_Tax::get_rate_code( $result['tax_rate_id'] ) ?? '',
1046 );
1047 $parsed_results[] = $result_data;
1048 }
1049
1050 return $parsed_results;
1051 }
1052
1053 /**
1054 * Get order lookup data from database.
1055 *
1056 * @param string $table_name The name of the table.
1057 * @param int $order_id The order ID.
1058 * @return array|bool The order lookup data or false if no data exists.
1059 */
1060 protected function get_order_lookup_data_from_db( $table_name, $order_id ) {
1061 global $wpdb;
1062
1063 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1064 $query = $wpdb->prepare(
1065 "SELECT * FROM {$wpdb->prefix}{$table_name} WHERE order_id = %d",
1066 $order_id
1067 );
1068 // phpcs:enable
1069
1070 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1071 $results = $wpdb->get_results( $query, ARRAY_A );
1072
1073 if ( empty( $results ) ) {
1074 return false;
1075 }
1076
1077 return $results;
1078 }
1079
1080 /**
1081 * Get the order stats data from the database.
1082 *
1083 * @param int $order_id The order ID.
1084 * @return array|bool The order stats data or false if the order stats item does not exist.
1085 */
1086 private function get_order_stats_data_from_db( $order_id ) {
1087 $order_stats_data = $this->get_order_stats_item( $order_id );
1088
1089 if ( ! $order_stats_data ) {
1090 return false;
1091 }
1092
1093 // Convert date strings to datetime objects.
1094 $order_stats_data['date_created'] = self::datetime_to_object( $order_stats_data['date_created'] );
1095 $order_stats_data['date_completed'] = self::datetime_to_object( $order_stats_data['date_completed'] );
1096 $order_stats_data['date_paid'] = self::datetime_to_object( $order_stats_data['date_paid'] );
1097
1098 return $order_stats_data;
1099 }
1100
1101 /**
1102 * Perform an order status discrepancy check between the order object and the item in the wc_order_stats table.
1103 *
1104 * @param WC_Order $order WC_Order object.
1105 * @param array $order_stats_item The order stats item.
1106 *
1107 * @return void
1108 */
1109 private function do_order_status_discrepancy_check( $order, $order_stats_item = array() ) {
1110 if ( ! $order instanceof WC_Abstract_Order ) {
1111 return;
1112 }
1113
1114 $order_id = $order->get_id();
1115
1116 // If the order_stats_item is empty, then fetch it from the wc_order_stats table.
1117 if ( empty( $order_stats_item ) ) {
1118 $order_stats_item = $this->get_order_stats_data_from_db( $order_id );
1119 }
1120
1121 // Check for discrepancy in the order status. Happens in old orders that were not updated and hence the OrderStatsFixer did not run.
1122 $normalized_order_status = self::normalize_order_status( $order->get_status() );
1123 if ( $order_stats_item && $normalized_order_status !== $order_stats_item['status'] ) {
1124 /**
1125 * Trigger the action to fix the order stats. The OrderStatusFixer should be hooked to this action.
1126 *
1127 * @param int $order_id The order ID.
1128 */
1129 do_action( 'woocommerce_analytics_incorrect_order_status_detected', $order_id );
1130 }
1131 }
1132 }
1133