Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 194
0.00% covered (danger)
0.00%
0 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
WooCommerce_HPOS_Orders
0.00% covered (danger)
0.00%
0 / 192
0.00% covered (danger)
0.00%
0 / 24
6162
0.00% covered (danger)
0.00%
0 / 1
 name
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 table_name
n/a
0 / 0
n/a
0 / 0
1
 table
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_order_types_to_sync
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 init_listeners
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 init_full_sync_listeners
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 init_before_send
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 id_field
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_full_sync_actions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_object_by_id
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 get_objects_by_id
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 expand_order_objects
n/a
0 / 0
n/a
0 / 0
1
 build_full_sync_action_array
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 expand_order_object
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 on_before_enqueue_order_save
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 on_before_enqueue_order_trash_delete
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 filter_order_data
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
650
 get_all_possible_order_status_keys
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_wc_order_status_with_prefix
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 wc_get_order_status_keys
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 get_metadata
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 estimate_full_sync_actions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 enqueue_full_sync_actions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_where_sql
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 get_next_chunk
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * WooCommerce HPOS orders sync module.
4 *
5 * @package automattic/jetpack-sync
6 */
7
8namespace Automattic\Jetpack\Sync\Modules;
9
10use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
11
12if ( ! defined( 'ABSPATH' ) ) {
13    exit( 0 );
14}
15
16/**
17 * Adds WooCommerce HPOS specific data to sync when HPOS is enabled on the site.
18 */
19class WooCommerce_HPOS_Orders extends Module {
20
21    /**
22     * The slug of WooCommerce Subscriptions plugin.
23     */
24    const WOOCOMMERCE_SUBSCRIPTIONS_PATH = 'woocommerce-subscriptions/woocommerce-subscriptions.php';
25
26    /**
27     * Order table name. There are four order tables (order, addresses, operational_data and meta), but for sync purposes we only care about the main table since it has the order ID.
28     *
29     * @access private
30     *
31     * @var string
32     */
33    private $order_table_name;
34
35    /**
36     * Sync module name.
37     *
38     * @access public
39     *
40     * @return string
41     */
42    public function name() {
43        return 'woocommerce_hpos_orders';
44    }
45
46    /**
47     * Get the order table name.
48     *
49     * @access public
50     *
51     * @return string
52     * @deprecated since 3.11.0 Use table() instead.
53     */
54    public function table_name() {
55        _deprecated_function( __METHOD__, '3.11.0', 'Automattic\\Jetpack\\Sync\\WooCommerce_HPOS_Orders->table' );
56        return $this->order_table_name;
57    }
58
59    /**
60     * The table in the database with the prefix.
61     *
62     * @access public
63     *
64     * @return string|bool
65     */
66    public function table() {
67        return $this->order_table_name;
68    }
69
70    /**
71     * Initialize order table data store, returns if the class don't exist (pre WC 6.x)
72     *
73     * @access public
74     */
75    public function __construct() {
76        if ( ! class_exists( OrdersTableDataStore::class ) ) {
77            return;
78        }
79        $this->order_table_name = OrdersTableDataStore::get_orders_table_name();
80    }
81
82    /**
83     * Get order types that we want to sync. Adding a new type here is not enough, we would also need to add its prop in filter_order_data method.
84     *
85     * @param bool $prefixed Whether to return prefixed types with shop_ or not.
86     *
87     * @return array Order types to sync.
88     */
89    public static function get_order_types_to_sync( $prefixed = false ) {
90        $types = array( 'order', 'order_refund' );
91
92        // Ensure this is available.
93        if ( ! function_exists( 'is_plugin_active' ) ) {
94            require_once ABSPATH . 'wp-admin/includes/plugin.php';
95        }
96
97        if ( is_plugin_active( self::WOOCOMMERCE_SUBSCRIPTIONS_PATH ) ) {
98            $types[] = 'subscription';
99        }
100
101        if ( $prefixed ) {
102            $types = array_map(
103                function ( $type ) {
104                    return "shop_{$type}";
105                },
106                $types
107            );
108        }
109        return $types;
110    }
111
112    /**
113     * Hooks sync listners on order modify events.
114     *
115     * @access public
116     *
117     * @param callable $callable Action handler callable.
118     */
119    public function init_listeners( $callable ) {
120        foreach ( self::get_order_types_to_sync() as $type ) {
121            add_action( "woocommerce_after_{$type}_object_save", $callable );
122            add_filter( "jetpack_sync_before_enqueue_woocommerce_after_{$type}_object_save", array( $this, 'on_before_enqueue_order_save' ) );
123        }
124        add_action( 'woocommerce_delete_order', $callable );
125        add_action( 'woocommerce_delete_subscription', $callable );
126        add_filter( 'jetpack_sync_before_enqueue_woocommerce_delete_order', array( $this, 'on_before_enqueue_order_trash_delete' ) );
127        add_filter( 'jetpack_sync_before_enqueue_woocommerce_delete_subscription', array( $this, 'on_before_enqueue_order_trash_delete' ) );
128        add_action( 'woocommerce_trash_order', $callable );
129        add_action( 'woocommerce_trash_subscription', $callable );
130        add_filter( 'jetpack_sync_before_enqueue_woocommerce_trash_order', array( $this, 'on_before_enqueue_order_trash_delete' ) );
131        add_filter( 'jetpack_sync_before_enqueue_woocommerce_trash_subscription', array( $this, 'on_before_enqueue_order_trash_delete' ) );
132    }
133
134    /**
135     * Hooks the full sync listeners.
136     *
137     * @access public
138     *
139     * @param callable $callable Action handler callable.
140     */
141    public function init_full_sync_listeners( $callable ) {
142        add_action( 'jetpack_full_sync_orders', $callable );
143    }
144
145    /**
146     * Initialize the module in the sender.
147     *
148     * @access public
149     */
150    public function init_before_send() {
151        // Incremental Sync
152        foreach ( self::get_order_types_to_sync() as $type ) {
153            add_filter( "jetpack_sync_before_send_woocommerce_after_{$type}_object_save", array( $this, 'expand_order_object' ) );
154        }
155        // Full sync.
156        add_filter( 'jetpack_sync_before_send_jetpack_full_sync_woocommerce_hpos_orders', array( $this, 'build_full_sync_action_array' ) );
157    }
158
159    /**
160     * Returns the ID field from wc_orders table.
161     *
162     * @access public
163     *
164     * @return string
165     */
166    public function id_field() {
167        return 'id';
168    }
169
170    /**
171     * Retrieve the actions that will be sent for this module during a full sync.
172     *
173     * @access public
174     *
175     * @return array Full sync actions of this module.
176     */
177    public function get_full_sync_actions() {
178        return array( 'jetpack_full_sync_orders' );
179    }
180
181    /**
182     * Retrieve order data by its ID.
183     *
184     * @access public
185     *
186     * @param string $object_type Type of object to retrieve. Should be `order`.
187     * @param int    $id          Order ID.
188     *
189     * @return array
190     */
191    public function get_object_by_id( $object_type, $id ) {
192        if ( 'order' !== $object_type ) {
193            return $id;
194        }
195        $order_objects = $this->get_objects_by_id( $object_type, array( $id ) );
196        return isset( $order_objects[ $id ] ) ? $order_objects[ $id ] : false;
197    }
198
199    /**
200     * Retrieves multiple orders data by their ID. Sorted by ID in descending order.
201     *
202     * @access public
203     *
204     * @param string $object_type Type of object to retrieve. Should be `order`.
205     * @param array  $ids         List of order IDs.
206     *
207     * @return array
208     */
209    public function get_objects_by_id( $object_type, $ids ) {
210        if ( 'order' !== $object_type || empty( $ids ) || ! is_array( $ids ) ) {
211            return array();
212        }
213
214        $orders = wc_get_orders(
215            array(
216                'post__in'    => $ids,
217                'type'        => self::get_order_types_to_sync( true ),
218                'post_status' => self::get_all_possible_order_status_keys(),
219                'limit'       => -1,
220                'orderby'     => 'ID',
221                'order'       => 'DESC',
222            )
223        );
224
225        $orders_data = array();
226        foreach ( $orders as $order ) {
227            $orders_data[ $order->get_id() ] = $this->filter_order_data( $order );
228        }
229
230        return $orders_data;
231    }
232
233    /**
234     * Retrieves multiple orders data by their ID.
235     *
236     * @access public
237     *
238     * @param array $args List of order IDs.
239     *
240     * @return array
241     * @deprecated since 4.7.0
242     */
243    public function expand_order_objects( $args ) {
244        _deprecated_function( __METHOD__, '4.7.0' );
245        list( $order_ids, $previous_end ) = $args;
246        return array(
247            'orders'       => $this->get_objects_by_id( 'order', $order_ids ),
248            'previous_end' => $previous_end,
249        );
250    }
251
252    /**
253     * Build the full sync action object.
254     *
255     * @access public
256     *
257     * @param array $args An array with filtered objects and previous end.
258     *
259     * @return array An array with orders and previous end.
260     */
261    public function build_full_sync_action_array( $args ) {
262        list( $filtered_orders, $previous_end ) = $args;
263        return array(
264            'orders'       => $filtered_orders['objects'],
265            'previous_end' => $previous_end,
266        );
267    }
268
269    /**
270     * Retrieve filtered order data before sending.
271     *
272     * @access public
273     *
274     * @param array $args An array with order data.
275     *
276     * @return array|false
277     */
278    public function expand_order_object( $args ) {
279        if ( empty( $args['id'] ) ) {
280            return false;
281        }
282
283        $order_object = wc_get_order( $args['id'] );
284
285        if ( ! $order_object instanceof \WC_Abstract_Order ) {
286            return false;
287        }
288
289        return $this->filter_order_data( $order_object );
290    }
291
292    /**
293     * Retrieve order data by its ID.
294     *
295     * @access public
296     *
297     * @param array $args Order ID.
298     *
299     * @return array|false
300     */
301    public function on_before_enqueue_order_save( $args ) {
302        // Prevent multiple triggers on a single request.
303        static $processed = array();
304
305        if ( ! is_array( $args ) || ! isset( $args[0] ) ) {
306            return false;
307        }
308        $order_object = $args[0];
309
310        if ( is_int( $order_object ) ) {
311            $order_object = wc_get_order( $order_object );
312        }
313
314        if ( ! $order_object instanceof \WC_Abstract_Order ) {
315            return false;
316        }
317
318        $order_id = $order_object->get_id();
319
320        if ( empty( $order_id ) ) {
321            return false;
322        }
323
324        if ( isset( $processed[ $order_id ] ) ) {
325            return false;
326        }
327
328        $processed[ $order_id ] = true;
329
330        return array( 'id' => $order_id );
331    }
332
333    /**
334     * Convert order ID to array.
335     *
336     * @access public
337     *
338     * @param array $args Order ID.
339     *
340     * @return array
341     */
342    public function on_before_enqueue_order_trash_delete( $args ) {
343        if ( ! is_array( $args ) || ! isset( $args[0] ) ) {
344            return false;
345        }
346        $order_id = $args[0];
347
348        if ( ! is_int( $order_id ) ) {
349            return false;
350        }
351
352        return array( 'id' => $order_id );
353    }
354
355    /**
356     * Filters only allowed keys from order data. No PII etc information is allowed to be synced.
357     *
358     * @access private
359     *
360     * @param \WC_Abstract_Order $order_object Order object.
361     *
362     * @return array Filtered order data.
363     */
364    private function filter_order_data( $order_object ) {
365        // Filter with allowlist.
366        $allowed_data_keys   = WooCommerce::$wc_post_meta_whitelist;
367        $core_table_keys     = array(
368            'id',
369            'status',
370            'date_created',
371            'date_modified',
372            'parent_id',
373        );
374        $allowed_data_keys   = array_merge( $allowed_data_keys, $core_table_keys );
375        $filtered_order_data = array( 'type' => $order_object->get_type() );
376        $order_data          = $order_object->get_data();
377        foreach ( $allowed_data_keys as $key ) {
378            $key       = trim( $key, '_' );
379            $key_parts = explode( '_', $key );
380
381            if ( in_array( $key_parts[0], array( 'order', 'refund' ), true ) ) {
382                if ( isset( $order_data[ $key_parts[1] ] ) && ! is_array( $order_data[ $key_parts[1] ] ) ) {
383                    $filtered_order_data[ $key ] = $order_data[ $key_parts[1] ];
384                    continue;
385                }
386            }
387
388            if ( in_array( $key_parts[0], array( 'billing', 'shipping' ), true ) && 2 === count( $key_parts ) ) {
389                if ( isset( $order_data[ $key_parts[0] ][ $key_parts[1] ] ) ) {
390                    $filtered_order_data[ $key ] = $order_data[ $key_parts[0] ][ $key_parts[1] ];
391                    continue;
392                }
393            }
394
395            /**
396             * We need to convert the WC_DateTime objects to stdClass objects to ensure they are properly encoded.
397             *
398             * @see Automattic\Jetpack\Sync\Functions::json_wrap as the return value of get_object_vars can vary depending on PHP version.
399             */
400            if ( in_array( $key, array( 'date_created', 'date_modified', 'date_paid', 'date_completed' ), true ) && isset( $order_data[ $key ] ) ) {
401                if ( is_a( $order_data[ $key ], 'WC_DateTime' ) ) {
402                    $filtered_order_data[ $key ] = (object) (array) $order_data[ $key ];
403                    continue;
404                }
405            }
406
407            if ( isset( $order_data[ $key ] ) ) {
408                $filtered_order_data[ $key ] = $order_data[ $key ];
409                continue;
410            }
411
412            switch ( $key ) {
413                case 'cart_discount':
414                    $filtered_order_data[ $key ] = isset( $order_data['discount_total'] ) ? $order_data['discount_total'] : '';
415                    break;
416                case 'cart_discount_tax':
417                    $filtered_order_data[ $key ] = isset( $order_data['discount_tax'] ) ? $order_data['discount_tax'] : '';
418                    break;
419                case 'order_shipping':
420                    $filtered_order_data[ $key ] = isset( $order_data['shipping_total'] ) ? $order_data['shipping_total'] : '';
421                    break;
422                case 'order_shipping_tax':
423                    $filtered_order_data[ $key ] = isset( $order_data['shipping_tax'] ) ? $order_data['shipping_tax'] : '';
424                    break;
425                case 'order_tax':
426                    $filtered_order_data[ $key ] = isset( $order_data['cart_tax'] ) ? $order_data['cart_tax'] : '';
427                    break;
428                case 'order_total':
429                    $filtered_order_data[ $key ] = isset( $order_data['total'] ) ? $order_data['total'] : '';
430                    break;
431            }
432        }
433        if ( '' === $filtered_order_data['status'] ) {
434            $filtered_order_data['status'] = 'pending';
435        }
436        $filtered_order_data['status'] = self::get_wc_order_status_with_prefix( $filtered_order_data['status'] );
437
438        /**
439         * Filter the order data before syncing.
440         *
441         * @since 3.7.0
442         *
443         * @param array              $filtered_order_data The Filtered order data.
444         * @param \WC_Abstract_Order $order_object        The Order object.
445         */
446        return apply_filters( 'jetpack_sync_filtered_hpos_order_data', $filtered_order_data, $order_object );
447    }
448
449    /**
450     * Returns all possible order status keys, including 'auto-draft' and 'trash'.
451     *
452     * @access public
453     *
454     * @return array An array of all possible status keys, including 'auto-draft' and 'trash'.
455     */
456    public static function get_all_possible_order_status_keys() {
457        $order_statuses    = array( 'auto-draft', 'trash' );
458        $wc_order_statuses = self::wc_get_order_status_keys();
459
460        return array_unique( array_merge( $wc_order_statuses, $order_statuses ) );
461    }
462
463    /**
464     * Add the 'wc-' order status to WC related order statuses.
465     *
466     * @param string $status The WC order status without the 'wc-' prefix.
467     *
468     * @return string The WC order status with the 'wc-' prefix if it's a valid order status, initial $status otherwise.
469     */
470    protected static function get_wc_order_status_with_prefix( string $status ) {
471        return in_array( 'wc-' . $status, self::wc_get_order_status_keys(), true ) ? 'wc-' . $status : $status;
472    }
473
474    /**
475     * Returns order status keys using 'wc_get_order_statuses', if possible.
476     *
477     * @see wc_get_order_statuses
478     *
479     * @return array Filtered order metadata.
480     */
481    private static function wc_get_order_status_keys() {
482        $wc_order_statuses = array();
483        if ( function_exists( 'wc_get_order_statuses' ) ) {
484            $wc_order_statuses   = array_keys( wc_get_order_statuses() );
485            $wc_order_statuses[] = 'wc-checkout-draft'; // Temp till Woo fixes a bug where this order status is missing.
486        } else {
487            $wc_order_statuses = array(
488                'wc-pending',
489                'wc-processing',
490                'wc-on-hold',
491                'wc-completed',
492                'wc-cancelled',
493                'wc-refunded',
494                'wc-failed',
495                'wc-checkout-draft',
496            );
497        }
498
499        if ( function_exists( 'wcs_get_subscription_statuses' ) ) {
500            // @phan-suppress-next-line PhanUndeclaredFunction -- Checked above. See also https://github.com/phan/phan/issues/1204.
501            $wc_subscription_statuses = array_keys( wcs_get_subscription_statuses() );
502            $wc_order_statuses        = array_merge( $wc_order_statuses, $wc_subscription_statuses );
503        }
504
505        return array_unique( $wc_order_statuses );
506    }
507
508    /**
509     * Returns metadata for order object.
510     *
511     * @access protected
512     *
513     * @param array  $ids List of order IDs.
514     * @param string $meta_type Meta type.
515     * @param array  $meta_key_whitelist List of allowed meta keys.
516     *
517     * @return array Filtered order metadata.
518     */
519    protected function get_metadata( $ids, $meta_type, $meta_key_whitelist ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- returning empty meta is intentional.
520        return array(); // don't sync metadata, all allow-listed core data is available in the order object.
521    }
522
523    /**
524     * Retrieve an estimated number of actions that will be enqueued.
525     *
526     * @access public
527     *
528     * @param array $config Full sync configuration for this sync module.
529     * @return int Number of items yet to be enqueued.
530     */
531    public function estimate_full_sync_actions( $config ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- We return all order count for full sync, so confit is not required.
532        global $wpdb;
533
534        $query = "SELECT count(*) FROM {$this->table()} WHERE {$this->get_where_sql( $config ) }";
535        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Hardcoded query, no user variable
536        $count = (int) $wpdb->get_var( $query );
537
538        return (int) ceil( $count / self::ARRAY_CHUNK_SIZE );
539    }
540
541    /**
542     * Enqueue the WooCommerce HPOS orders actions for full sync.
543     *
544     * @access public
545     *
546     * @param array   $config               Full sync configuration for this sync module.
547     * @param int     $max_items_to_enqueue Maximum number of items to enqueue.
548     * @param boolean $state                True if full sync has finished enqueueing this module, false otherwise.
549     * @return array Number of actions enqueued, and next module state.
550     */
551    public function enqueue_full_sync_actions( $config, $max_items_to_enqueue, $state ) {
552        return $this->enqueue_all_ids_as_action( 'full_sync_orders', $this->table(), 'id', $this->get_where_sql( $config ), $max_items_to_enqueue, $state );
553    }
554
555    /**
556     * Get where SQL for full sync.
557     *
558     * @access public
559     *
560     * @param array $config Full sync configuration for this sync module.
561     *
562     * @return string WHERE SQL clause, or `null` if no comments are specified in the module config.
563     */
564    public function get_where_sql( $config ) {
565        global $wpdb;
566        $parent_where           = parent::get_where_sql( $config );
567        $order_types            = self::get_order_types_to_sync( true );
568        $order_type_placeholder = implode( ', ', array_fill( 0, count( $order_types ), '%s' ) );
569        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Query is prepared.
570        $where_sql = $wpdb->prepare( "type IN ( $order_type_placeholder )", $order_types );
571        return "{$parent_where} AND {$where_sql}";
572    }
573
574    /**
575     * Given the Module Configuration and Status return the next chunk of items to send.
576     * This function also expands the posts and metadata and filters them based on the maximum size constraints.
577     *
578     * @param array $config This module Full Sync configuration.
579     * @param array $status This module Full Sync status.
580     * @param int   $chunk_size Chunk size.
581     *
582     * @return array
583     */
584    public function get_next_chunk( $config, $status, $chunk_size ) {
585
586        $order_ids = parent::get_next_chunk( $config, $status, $chunk_size );
587
588        if ( empty( $order_ids ) ) {
589            return array();
590        }
591
592        $orders = $this->get_objects_by_id( 'order', $order_ids );
593
594        // If no orders were fetched, make sure to return the expected structure so that status is updated correctly.
595        if ( empty( $orders ) ) {
596            return array(
597                'object_ids' => $order_ids,
598                'objects'    => array(),
599            );
600        }
601
602        // Filter the orders based on the maximum size constraints. We don't need to filter metadata here since we don't sync it for hpos.
603        list( $filtered_order_ids, $filtered_orders, ) = $this->filter_objects_and_metadata_by_size(
604            'order',
605            $orders,
606            array(),
607            0,
608            self::MAX_SIZE_FULL_SYNC
609        );
610
611        return array(
612            'object_ids' => $filtered_order_ids,
613            'objects'    => $filtered_orders,
614        );
615    }
616}