Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 990
0.00% covered (danger)
0.00%
0 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Woo_Sync_Background_Sync_Job
0.00% covered (danger)
0.00%
0 / 989
0.00% covered (danger)
0.00%
0 / 24
76452
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 woosync
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 settings
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 import_mode
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 debug
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 run_sync
0.00% covered (danger)
0.00%
0 / 68
0.00% covered (danger)
0.00%
0 / 1
210
 import_page_of_orders
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 import_orders_from_store
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
56
 import_orders_from_api
0.00% covered (danger)
0.00%
0 / 72
0.00% covered (danger)
0.00%
0 / 1
132
 add_update_from_woo_order
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
20
 set_first_import_status
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 first_import_completed
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 set_resume_from_page
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 resume_from_page
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 import_crm_object_data
0.00% covered (danger)
0.00%
0 / 126
0.00% covered (danger)
0.00%
0 / 1
1332
 woocommerce_order_to_crm_objects
0.00% covered (danger)
0.00%
0 / 421
0.00% covered (danger)
0.00%
0 / 1
16256
 woocommerce_api_order_to_crm_objects
0.00% covered (danger)
0.00%
0 / 76
0.00% covered (danger)
0.00%
0 / 1
90
 percentage_completed
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
110
 filter_checkout_contact_fields
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 checkout_field_editor_filter_field
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
72
 checkout_field_editor_pro_filter_field
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 checkout_add_ons_add_field_values
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
156
 log_connection_error
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 pause_site_due_to_connection_error
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/*
3 * Jetpack CRM
4 * https://jetpackcrm.com
5 *
6 * WooSync: Background Sync Job (per run, site connection)
7 *
8 */
9namespace Automattic\JetpackCRM;
10
11// block direct access
12defined( 'ZEROBSCRM_PATH' ) || exit( 0 );
13
14#} the WooCommerce API
15use Automattic\WooCommerce\HttpClient\HttpClientException;
16
17/**
18 * WooSync Background Sync Job class
19 */
20class Woo_Sync_Background_Sync_Job {
21
22    /**
23     * Site Key
24     */
25    private $site_key = false;
26
27    /**
28     * Site Info
29     */
30    private $site_info = false;
31
32    /**
33     * Paused state
34     */
35    private $paused = false;
36
37    /**
38     * Number of orders to process per job
39     */
40    private $orders_per_page = 50;
41    private $pages_per_job   = 1;
42
43    /**
44     * Current page the job is working on
45     */
46    private $current_page = 1;
47
48    /**
49     * Number of pages in Woo
50     */
51    private $woo_total_pages = 0;
52
53    /**
54     * Number of orders in Woo
55     */
56    private $woo_total_orders = 0;
57
58    /**
59     * If set to true this will echo progress of a sync job.
60     */
61    public $debug = false;
62
63    /**
64     * Setup WooSync Background Sync
65     * Note: This will effectively fire after core settings and modules loaded
66     * ... effectively on tail end of `init`
67     */
68    public function __construct( $site_key = '', $site_info = false, $debug = false, $orders_per_page = 50, $pages_per_job = 1 ) {
69
70        // requires key
71        if ( empty( $site_key ) ) {
72
73            // fail.
74            return false;
75
76        }
77
78        // set vars
79        $this->site_key        = $site_key;
80        $this->site_info       = $site_info;
81        $this->debug           = $debug;
82        $this->orders_per_page = $orders_per_page;
83        $this->pages_per_job   = $pages_per_job;
84
85        // load where not passed
86        if ( ! is_array( $this->site_info ) ) {
87
88            $this->site_info = $this->woosync()->get_active_sync_site( $this->site_key );
89
90        }
91
92        // promote paused state
93        if ( isset( $this->site_info['paused'] ) && $this->site_info['paused'] ) {
94
95            $this->paused = true;
96
97        }
98
99        // good to go?
100        if ( ! is_array( $this->site_info ) ) {
101
102            return false;
103
104        }
105    }
106
107    /**
108     * Returns main class instance
109     */
110    public function woosync() {
111
112        global $zbs;
113        return $zbs->modules->woosync;
114    }
115
116    /**
117     * Returns full settings array from main settings class
118     */
119    public function settings() {
120
121        return $this->woosync()->settings->getAll();
122    }
123
124    /**
125     * Returns 'local' or 'api'
126     *  (whichever mode is selected in settings)
127     */
128    public function import_mode( $str_mode = false ) {
129
130        // import mode
131        $mode = (int) $this->site_info['mode'];
132
133        // debug/string mode
134        if ( $str_mode ) {
135            if ( $mode === 0 ) {
136                return 'JPCRM_WOO_SYNC_MODE_LOCAL';
137            } else {
138                return 'JPCRM_WOO_SYNC_MODE_API';
139            }
140        }
141
142        return $mode;
143    }
144
145    /**
146     * If $this->debug is true, outputs passed string
147     *
148     * @param string - Debug string
149     */
150    private function debug( $str ) {
151
152        if ( $this->debug ) {
153
154            echo '[' . zeroBSCRM_locale_utsToDatetime( time() ) . '] ' . $str . '<br>';
155
156        }
157    }
158
159    /**
160     * Main job function: this will retrieve and import orders from WooCommerce into CRM.
161     * for a given sync site
162     *
163     * @return mixed (int|json)
164     *   - if cron originated: a count of orders imported is returned
165     *   - if not cron originated (assumes AJAX):
166     *      - if completed sync: JSON summary info is output and then exit() is called
167     *      - else count of orders imported is returned
168     */
169    public function run_sync() {
170
171        global $zbs;
172
173        $this->debug( 'Fired `sync_orders()` for `' . $this->site_key . '`.<pre>' . print_r( $this->site_info, 1 ) . '</pre>' );
174
175        if ( ! is_array( $this->site_info ) ) {
176
177            // debug
178            $this->debug( 'Failed to retrieve site to sync! ' );
179            return false;
180
181        }
182
183        // prep vars
184        $run_sync_job          = true;
185        $total_remaining_pages = 0;
186        $total_pages           = 0;
187        $errors                = array();
188
189        // check not marked 'paused'
190        if ( $this->paused ) {
191
192            // skip it
193            $this->debug( 'Skipping Sync for ' . $this->site_info['domain'] . ' (mode: ' . $this->site_info['mode'] . ') - Paused' );
194            $run_sync_job = false;
195
196        }
197
198        $this->debug( 'Starting Sync for ' . $this->site_info['domain'] . ' (mode: ' . $this->site_info['mode'] . ')' );
199
200        // switch by mode
201        if ( $this->site_info['mode'] == JPCRM_WOO_SYNC_MODE_API ) {
202
203            // vars
204            $domain = $this->site_info['domain'];
205            $key    = $this->site_info['key'];
206            $secret = $this->site_info['secret'];
207            $prefix = $this->site_info['prefix'];
208
209            // confirm settings
210            if ( empty( $domain ) || empty( $key ) || empty( $secret ) ) {
211
212                $status_short_text = __( 'Setup required', 'zero-bs-crm' );
213
214                $this->debug( $status_short_text );
215
216                $errors[] = array(
217                    'status'            => 'error',
218                    'status_short_text' => $status_short_text,
219                    'status_long_text'  => sprintf( __( 'WooSync will start importing data when you have updated your settings. Your site connection <code>%s</code> needs more information to connect.', 'zero-bs-crm' ), $this->site_info['domain'] ),
220                    'error'             => 'external_no_settings',
221                );
222
223                // skip this site connection
224                $run_sync_job = false;
225
226            }
227        } elseif ( $this->site_info['mode'] == JPCRM_WOO_SYNC_MODE_LOCAL ) {
228
229            // local install
230
231            // verify woo installed
232            if ( ! $zbs->woocommerce_is_active() ) {
233
234                $status_short_text = __( 'Missing WooCommerce', 'zero-bs-crm' );
235
236                $this->debug( $status_short_text );
237
238                $errors[] = array(
239                    'status'            => 'error',
240                    'status_short_text' => $status_short_text,
241                    'status_long_text'  => __( 'WooSync will start importing data when you have installed WooCommerce.', 'zero-bs-crm' ),
242                    'error'             => 'local_no_woocommerce',
243                );
244
245                // skip this site connection
246                $run_sync_job = false;
247
248            }
249        } else {
250
251            // no mode, or a faulty one!
252            $this->debug( 'Mode unacceptable' );
253
254            $errors[] = array(
255                'status'            => 'error',
256                'status_short_text' => $status_short_text,
257                'status_long_text'  => __( 'WooSync could not sync because one of your store connections is in an unacceptable mode.', 'zero-bs-crm' ),
258                'error'             => 'local_no_woocommerce',
259            );
260
261            // skip this site connection
262            $run_sync_job = false;
263
264        }
265
266        if ( $run_sync_job ) {
267
268            $this->debug( 'Running Import of ' . $this->pages_per_job . ' pages' );
269
270            // do x pages
271            for ( $i = 0; $i < $this->pages_per_job; $i++ ) {
272
273                // get last working position
274                $page_to_retrieve = $this->resume_from_page();
275
276                // ... if for some reason we've got a negative, start from scratch.
277                if ( $page_to_retrieve < 1 ) {
278
279                    $page_to_retrieve = 1;
280
281                }
282
283                $this->current_page = $page_to_retrieve;
284                // import the page of orders
285                // This always returns the count of imported orders,
286                // unless 100% sync is reached, at which point it will exit (if called via AJAX)
287                // for now, we don't need to track the return
288                $this->import_page_of_orders( $page_to_retrieve );
289
290            }
291
292            // mark the pass
293            $this->woosync()->set_sync_site_attribute( $this->site_key, 'last_sync_fired', time() );
294            $this->debug( 'Sync Job finished for ' . $this->site_info['domain'] . ' with percentage complete: ' . $this->percentage_completed( false ) . '% complete.' );
295
296        }
297
298        // return overall % counts later used to provide a summary % across sync site connections
299        $percentage_counts = $this->percentage_completed( true );
300        if ( is_array( $percentage_counts ) ) {
301
302            $total_pages           = (int) $percentage_counts['total_pages'];
303            $total_remaining_pages = $percentage_counts['total_pages'] - $percentage_counts['page_no'];
304
305        }
306
307        // We should never have less than zero here
308        // (seems to happen when site connections error out)
309        if ( $total_remaining_pages < 0 ) {
310            $total_remaining_pages = 0;
311        }
312
313        return array(
314
315            'total_pages'           => $total_pages,
316            'total_remaining_pages' => $total_remaining_pages,
317            'errors'                => $errors,
318
319        );
320    }
321
322    /**
323     * Retrieve and process 1 page of WooCommerce orders via API or from local store
324     *
325     * @param int $page_no - the page number to start from
326     *
327     * @return mixed (int|json)
328     *   - if cron originated: a count of orders imported is returned
329     *   - if not cron originated (assumes AJAX):
330     *      - if completed sync: JSON summary info is output and then exit() is called
331     *      - else count of orders imported is returned
332     */
333    private function import_page_of_orders( $page_no ) {
334
335        $this->debug( 'Fired `import_page_of_orders( ' . $page_no . ' )`, importing from ' . $this->import_mode( true ) . ' on site ' . $this->site_key . '.' );
336
337        // store/api switch
338        if ( $this->import_mode() === JPCRM_WOO_SYNC_MODE_API ) {
339
340            // API
341            return $this->import_orders_from_api( $page_no );
342
343        } else {
344
345            return $this->import_orders_from_store( $page_no );
346
347        }
348    }
349
350    /**
351     * Retrieve and process a page of WooCommerce orders from local store
352     *  Previously `get_orders_from_store`
353     *
354     * @param int $page_no
355     *
356     * @return mixed (int|json)
357     *   - if cron originated: a count of orders imported is returned
358     *   - if not cron originated (assumes AJAX):
359     *      - if completed sync: JSON summary info is output and then exit() is called
360     *      - else count of orders imported is returned
361     */
362    public function import_orders_from_store( $page_no = -1 ) {
363
364        // Where we're trying to run without WooCommerce, fail.
365        // In theory we shouldn't ever hit this, as we catch it earlier.
366        global $zbs;
367        if ( ! $zbs->woocommerce_is_active() ) {
368            $this->debug( 'Unable to import as it appears WooCommerce is not installed.' );
369            return false;
370        }
371
372        // retrieve orders
373        $orders = wc_get_orders(
374            array(
375                'limit'    => $this->orders_per_page,
376                'paged'    => $page_no,
377                'paginate' => true,
378                'order'    => 'ASC',
379                'orderby'  => 'ID',
380            )
381        );
382
383        // count the pages and break if we have nothing to import
384        if ( $orders->max_num_pages == 0 ) {
385
386            // we're at 100%, mark sync complete
387            $this->set_first_import_status( true );
388
389            // return count
390            return 0;
391
392        }
393
394        // cache values
395        $this->woo_total_pages  = $orders->max_num_pages;
396        $this->woo_total_orders = $orders->total;
397
398        // we have some pages to process, so proceed
399        $orders_imported = 0;
400
401        // cycle through orders from store and import
402        foreach ( $orders->orders as $order ) {
403
404            // We previously used the wp cpt ID, see #1982
405            // In case we hit issues where a user sees dupes from this, we'll store any != in an extra meta
406            $order_post_id = $order->get_id();
407
408            // Get order number if there is one; for example refunds don't have 'get_order_number'
409            if ( method_exists( $order, 'get_order_number' ) ) {
410                $order_num = $order->get_order_number();
411            } else {
412                // order number by default is the same as the order post ID
413                $order_num = $order_post_id;
414            }
415
416            if ( ! empty( $order_post_id ) ) {
417
418                $this->debug( 'Importing order: ' . $order_num . '(' . $order_post_id . ')' );
419
420                // this seems perhaps unperformant given we have the `order` object
421                // ... and this function re-get's the order object, but it's centralised and useful (and #legacy)
422                $this->add_update_from_woo_order( $order_post_id );
423
424                // this will include orders updated...
425                ++$orders_imported;
426
427            }
428        }
429
430        // check for completion
431        if ( $page_no >= $orders->max_num_pages ) {
432
433            // we're at 100%, mark sync complete
434            $this->set_first_import_status( true );
435
436            // set pointer to last page
437            $this->set_resume_from_page( $orders->max_num_pages );
438
439            // return count
440            return $orders_imported;
441
442        }
443
444        // There's still pages to go then:
445
446        // increase pointer by one
447        $this->set_resume_from_page( $page_no + 1 );
448
449        // return the count
450        return $orders_imported;
451    }
452
453    /**
454     * Retrieve and process a page of WooCommerce orders via API
455     *  Previously `get_orders_from_api`
456     *
457     * @param int $page_no
458     *
459     * @return mixed (int|json)
460     *   - if cron originated: a count of orders imported is returned
461     *   - if not cron originated (assumes AJAX):
462     *      - if completed sync: JSON summary info is output and then exit() is called
463     *      - else count of orders imported is returned
464     */
465    public function import_orders_from_api( $page_no = -1 ) {
466
467        global $zbs;
468
469        try {
470
471            // get client
472            $woocommerce = $this->woosync()->get_woocommerce_client( $this->site_key );
473
474            $this->debug( 'Got WooCommerce Client...' );
475
476            // clock origin
477            $origin = '';
478            $domain = $this->site_info['domain'];
479            if ( ! empty( $domain ) ) {
480
481                // if Domain
482                if ( $domain ) {
483
484                    $origin = $zbs->DAL->add_origin_prefix( $domain, 'domain' );
485
486                }
487            }
488
489            // retrieve orders
490            // http://woocommerce.github.io/woocommerce-rest-api-docs/#orders
491            $orders = $woocommerce->get(
492                'orders',
493                array(
494                    'page'     => $page_no,
495                    'per_page' => $this->orders_per_page,
496                    'order'    => 'asc',
497                    'orderby'  => 'id',
498                )
499            );
500
501            // retrieve page count from headers:
502            $last_response       = $woocommerce->http->getResponse();
503            $response_headers    = $last_response->getHeaders();
504            $lc_response_headers = array_change_key_case( $response_headers, CASE_LOWER );
505
506            // error if X-WP-TotalPages header doesn't exist
507            if ( ! isset( $lc_response_headers['x-wp-totalpages'] ) ) {
508
509                wp_send_json(
510                    array(
511                        'status'               => 'error',
512                        'status_short_text'    => 'woo_api_missing_headers',
513                        'status_long_text'     => __( 'Missing headers in API response. It seems that WooCommerce has not responded in a standard way.', 'zero-bs-crm' ),
514                        'page_no'              => $page_no,
515                        'orders_imported'      => 0,
516                        'percentage_completed' => 0,
517                    )
518                );
519            }
520
521            // cache values
522            $this->woo_total_pages  = (int) $lc_response_headers['x-wp-totalpages'];
523            $this->woo_total_orders = (int) $lc_response_headers['x-wp-total'];
524
525            $total_pages = (int) $lc_response_headers['x-wp-totalpages'];
526
527            $this->debug(
528                'API Response:<pre>' . var_export(
529                    array(
530
531                        'orders_retrieved' => count( $orders ),
532                        // 'last_response'       => $last_response,
533                        // 'response_headers'    => $response_headers,
534                        // 'lc_response_headers' => $lc_response_headers,
535                        'total_pages'      => $this->woo_total_pages,
536
537                    ),
538                    true
539                ) . '</pre>'
540            );
541
542            // count the pages and break if we have nothing to import
543            if ( $this->woo_total_pages === 0 ) {
544
545                // we're at 100%, mark sync complete
546                $this->set_first_import_status( true );
547
548                // return count
549                return 0;
550            }
551
552            // we have some pages to process, so proceed
553            $orders_imported = 0;
554
555            // cycle through orders
556            foreach ( $orders as $order ) {
557
558                $this->debug( 'Importing order: ' . $order->number . ' (becoming: ' . $this->woosync()->get_prefix( $this->site_key ) . $order->number . ')' );
559
560                // prefix ID and number
561                $order->number = $this->woosync()->get_prefix( $this->site_key ) . $order->number;
562                $order->id     = $this->woosync()->get_prefix( $this->site_key ) . $order->id;
563
564                // translate order data to crm objects
565                $crm_objects = $this->woocommerce_api_order_to_crm_objects( $order, $origin );
566
567                // import crm objects
568                $this->import_crm_object_data( $crm_objects );
569
570                ++$orders_imported;
571
572            }
573
574            // check for completion
575            if ( $page_no >= $this->woo_total_pages ) {
576
577                // we're at 100%, mark sync complete
578                $this->set_first_import_status( true );
579
580                // set pointer to last page
581                $this->set_resume_from_page( $this->woo_total_pages );
582
583            } else {
584
585                // There's still pages to go then:
586
587                // increase pointer by one
588                $this->set_resume_from_page( $page_no + 1 );
589
590            }
591
592            // connection worked, so reset any errors:
593            $this->woosync()->set_sync_site_attribute( $this->site_key, 'site_connection_errors', 0 );
594
595            // return count
596            return $orders_imported;
597
598        } catch ( HttpClientException $e ) {
599
600            $this->debug( 'Sync Failed in `import_orders_from_api()`, WooCommerce REST API error: ' . $e->getMessage() );
601
602            // log connection error (3x = auto-pause)
603            $this->log_connection_error();
604
605            return 'error';
606
607        } catch ( Missing_Settings_Exception $e ) {
608
609            // missing settings means couldn't load lib.
610
611            // compile string of what's missing
612            $missing_string = '';
613            $missing_data   = $e->get_error_data();
614            if ( is_array( $missing_data ) && isset( $missing_data['missing'] ) ) {
615                $missing_string = '<br>' . __( 'Missing:', 'zero-bs-crm' ) . ' ' . implode( ', ', $missing_data['missing'] );
616            }
617
618            $this->debug( 'Sync Failed in `import_orders_from_api()` due to missing settings against `' . $this->site_key . '` (could not, therefore, load WooCommerce API Connection): ' . $e->getMessage() . $missing_string );
619
620            // log connection error (3x = auto-pause)
621            $this->log_connection_error();
622
623            return 'error';
624
625        }
626    }
627
628    /**
629     * Add or Update an order from WooCommerce
630     *  (previously `add_order_from_id`)
631     *
632     * @param int $order_post_id Order post id from WooCommerce (may be different than $order_num)
633     */
634    public function add_update_from_woo_order( $order_post_id ) {
635
636        global $zbs;
637
638        // This is only fired from local store calls, so let's retrieve the local domain as origin
639        $origin = '';
640        $domain = site_url();
641        if ( $domain ) {
642            $origin = $zbs->DAL->add_origin_prefix( $domain, 'domain' );
643        }
644
645        // get order data
646        $order = wc_get_order( $order_post_id );
647
648        // return if order doesn't exist
649        if ( ! $order ) {
650            return false;
651        }
652
653        $extra_meta = array();
654
655        // Get order number if there is one; for example:
656        // * refunds don't have 'get_order_number'
657        // * some plugins like Sequential Order Numbers Pro set a custom order number
658        if ( method_exists( $order, 'get_order_number' ) ) {
659
660            $order_num = $order->get_order_number();
661
662            // store the order number for future reference
663            $extra_meta['order_num'] = $order_num;
664
665        } else {
666            // order number by default is the same as the order post ID
667            $order_num = $order_post_id;
668        }
669
670        $raw_order_data = $order->get_data();
671
672        // consolidate data
673        $tidy_order_data = $this->woocommerce_order_to_crm_objects(
674            $raw_order_data,
675            $order,
676            $order_post_id,
677            $order_num,
678            '',
679            '',
680            false,
681            array(),
682            $origin,
683            $extra_meta
684        );
685
686        // import data
687        $this->import_crm_object_data( $tidy_order_data );
688    }
689
690    /**
691     * Set's a completion status for woo order imports
692     *
693     * @param string|bool $status = 'yes|no' (#legacy) or 'true|false'
694     *
695     * @return bool $status
696     */
697    public function set_first_import_status( $status ) {
698
699        $status_bool = false;
700
701        if ( $status == 'yes' || $status === true ) {
702
703            $status_bool = true;
704
705        }
706
707        // set it
708        $this->woosync()->set_sync_site_attribute( $this->site_key, 'first_import_complete', $status_bool );
709
710        return $status_bool;
711    }
712
713    /**
714     * Returns a completion status for woo order imports
715     *
716     * @return bool $status
717     */
718    public function first_import_completed() {
719
720        $status_bool = false;
721
722        // get
723        $sync_site = $this->woosync()->get_active_sync_site( $this->site_key );
724
725        if ( $sync_site['first_import_complete'] == 'yes' || $sync_site['first_import_complete'] === true || $sync_site['first_import_complete'] == 1 ) {
726
727            $status_bool = true;
728
729        }
730
731        return $status_bool;
732    }
733
734    /**
735     * Sets current working page index (to resume from)
736     *
737     * @return int $page
738     */
739    public function set_resume_from_page( $page_no ) {
740
741        // update_option( 'zbs_woo_resume_sync_' . $this->site_key, $page_no );
742        $this->woosync()->set_sync_site_attribute( $this->site_key, 'resume_from_page', $page_no );
743
744        return $page_no;
745    }
746
747    /**
748     * Return current working page index (to resume from)
749     *
750     * @return int $page
751     */
752    public function resume_from_page() {
753
754        return $this->woosync()->get_sync_site_attribute( $this->site_key, 'resume_from_page', 1 );
755    }
756
757    /**
758     * Adds or updates crm objects related to a processed woocommerce order
759     *  (requires that the $order_data has been passed through `woocommerce_order_to_crm_objects`)
760     *  Previously `import_woocommerce_order_from_order_data`
761     *
762     * @param array $crm_object_data (Woo Order data passed through `woocommerce_order_to_crm_objects`)
763     *
764     * @return int $transaction_id
765     */
766    public function import_crm_object_data( $crm_object_data ) {
767
768        global $zbs;
769
770        $settings = $this->settings();
771
772        // Add/update contact from cleaned order data, (previously `add_or_update_contact_from_order_data`)
773        $contact_id = -1;
774        if ( isset( $crm_object_data['contact'] ) && isset( $crm_object_data['contact']['email'] ) ) {
775
776            // Add the contact
777            $contact_id = $zbs->DAL->contacts->addUpdateContact(
778                array(
779                    'data'                 => $crm_object_data['contact'],
780                    'extraMeta'            => $crm_object_data['contact_extra_meta'],
781                    'do_not_update_blanks' => true,
782                )
783            );
784
785        }
786
787        // if contact: add logs, contact id relations to objects, and addupdate company
788        if ( $contact_id > 0 ) {
789
790            $this->debug( 'Contact added/updated #' . $contact_id );
791
792            $zbs->DAL->contacts->addUpdateContactTags( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
793                array(
794                    'id'        => $contact_id,
795                    'tag_input' => $crm_object_data['contact']['tags'],
796                    'mode'      => 'append',
797                )
798            );
799
800            // contact logs
801            if ( is_array( $crm_object_data['contact_logs'] ) ) {
802
803                foreach ( $crm_object_data['contact_logs'] as $log ) {
804
805                    // add log
806                    $log_id = $zbs->DAL->logs->addUpdateLog(
807                        array(
808
809                            'id'                           => -1,
810                            'owner'                        => -1,
811                            'ignore_if_existing_desc_type' => true,
812                            'ignore_if_meta_matching'      => array(
813                                'key'   => 'from_woo_order',
814                                'value' => $crm_object_data['order_post_id'],
815                            ),
816
817                            // fields (directly)
818                            'data'                         => array(
819
820                                'objtype'   => ZBS_TYPE_CONTACT,
821                                'objid'     => $contact_id,
822                                'type'      => $log['type'],
823                                'shortdesc' => $log['shortdesc'],
824                                'longdesc'  => $log['longdesc'],
825
826                                'meta'      => array( 'from_woo_order' => $crm_object_data['order_post_id'] ),
827                                'created'   => -1,
828
829                            ),
830
831                        )
832                    );
833
834                }
835            }
836
837            // add contact ID relationship to the related objects
838            $crm_object_data['transaction']['contacts'] = array( $contact_id );
839            $crm_object_data['invoice']['contacts']     = array( $contact_id );
840
841            // Add/update company (if using b2b mode, and successfully added/updated contact):
842            $b2b_mode = zeroBSCRM_getSetting( 'companylevelcustomers' );
843            if ( $b2b_mode && isset( $crm_object_data['company']['name'] ) && ! empty( $crm_object_data['company']['name'] ) ) {
844
845                /**
846                 * Note: we use existing company ID if we can find it by name; otherwise we create it
847                 *
848                 * WooCommerce orders only has one company field that we can use (company name). As such,
849                 * we can't rely on more accurate searches (e.g. by email)
850                 */
851                $potential_company = $zbs->DAL->companies->getCompany( -1, array( 'name' => $crm_object_data['company']['name'] ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
852
853                if ( $potential_company ) {
854                    $company_id = $potential_company['id'];
855                } else {
856                    // Add the company
857                    $company_id = $zbs->DAL->companies->addUpdateCompany( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
858                        array(
859                            'data' => $crm_object_data['company'],
860                        )
861                    );
862                }
863
864                if ( $company_id > 0 ) {
865
866                    $this->debug( 'Company added/updated #' . $company_id );
867
868                    // inject into transaction data too
869                    $crm_object_data['transaction']['companies'] = array( $company_id );
870                    $zbs->DAL->contacts->addUpdateContactCompanies(
871                        array(
872                            'id'         => $contact_id,
873                            'companyIDs' => array( $company_id ),
874                        )
875                    );
876
877                } else {
878
879                        $this->debug( 'Company import failed: <code>' . json_encode( $crm_object_data['company'] ) . '</code>' );
880
881                }
882            }
883        } else {
884
885            // failed to add contact?
886            $this->debug( 'Contact import failed, or there was no contact to import. Contact Data: <code>' . json_encode( $crm_object_data['contact'] ) . '</code>' );
887
888        }
889
890        // Add/update invoice (if enabled) (previously `add_or_update_invoice`), while checking for a 'Do not create' status to avoid creating this invoice if the mapping doesn't allow it
891        // @phan-suppress-next-line PhanTypeInvalidDimOffset False positive
892        if ( $settings['wcinv'] == 1 && isset( $crm_object_data['invoice'] ) && isset( $crm_object_data['invoice']['status'] ) && $crm_object_data['invoice']['status'] !== JPCRM_WOOSYNC_DO_NOT_CREATE['id'] ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
893
894            // retrieve existing invoice
895            // note this is substituting $crm_object_data['invoice']['existence_check_args'] for what should be $args, but it works
896            $invoice_id = $zbs->DAL->invoices->getInvoice( -1, $crm_object_data['invoice']['existence_check_args'] );
897
898            // add logo if invoice doesn't exist yet
899            if ( ! $invoice_id ) {
900                $crm_object_data['invoice']['logo_url'] = jpcrm_business_logo_url();
901            } else {
902                // if this is an update, let's not overwrite existing hash and logo
903                $old_invoice_data                       = $zbs->DAL->invoices->getInvoice( $invoice_id );
904                $crm_object_data['invoice']['logo_url'] = $old_invoice_data['logo_url'];
905                $crm_object_data['invoice']['hash']     = $old_invoice_data['hash'];
906            }
907
908            // add/update invoice
909            $invoice_id = $zbs->DAL->invoices->addUpdateInvoice(
910                array(
911                    'id'               => $invoice_id,
912                    'data'             => $crm_object_data['invoice'],
913                    'extraMeta'        => ( isset( $crm_object_data['invoice']['extra_meta'] ) ? $crm_object_data['invoice']['extra_meta'] : -1 ),
914                    'calculate_totals' => true,
915                )
916            );
917
918            // link the transaction to the invoice
919            if ( ! empty( $invoice_id ) ) {
920
921                $this->debug( 'Added invoice #' . $invoice_id );
922
923                $crm_object_data['transaction']['invoice_id'] = $invoice_id;
924
925            } else {
926
927                $this->debug( 'invoice import failed: <code>' . json_encode( $crm_object_data['invoice'] ) . '</code>' );
928
929            }
930        }
931
932        // Add/update transaction (previously `add_or_update_transaction`)
933        // note this is substituting $crm_object_data['invoice']['existence_check_args'] for what should be $args, but it works
934        $existing_transaction_id = $zbs->DAL->transactions->getTransaction( -1, $crm_object_data['transaction']['existence_check_args'] );
935
936        if ( ! empty( $existing_transaction_id ) ) {
937            $this->debug( 'Existing transaction #' . $existing_transaction_id );
938        }
939
940        $args = array(
941            'id'    => $existing_transaction_id,
942            'owner' => -1,
943            'data'  => $crm_object_data['transaction'],
944        );
945
946        // got any extra meta?
947        if ( isset( $crm_object_data['transaction_extra_meta'] ) && is_array( $crm_object_data['transaction_extra_meta'] ) ) {
948
949            $args['extraMeta'] = $crm_object_data['transaction_extra_meta'];
950
951        }
952
953        // This parameter (do_not_mark_invoices) makes sure invoice status are not changed.
954        $args['do_not_mark_invoices'] = true;
955        $transaction_id               = $zbs->DAL->transactions->addUpdateTransaction( $args );
956
957        if ( ! empty( $transaction_id ) ) {
958
959            // if we have success here, but we didn't have a previous id, then it's a successful new order addition
960            if ( empty( $existing_transaction_id ) ) {
961
962                // increment connection order import count
963                $this->woosync()->increment_sync_site_count( $this->site_key, 'total_order_count' );
964
965                $this->debug( 'Added transaction #' . $transaction_id );
966
967            } else {
968
969                $this->debug( 'Updated transaction #' . $transaction_id );
970
971            }
972        } else {
973
974            $this->debug( 'Transaction import failed: <code>' . json_encode( $crm_object_data['transaction'] ) . '</code>' );
975
976        }
977
978        // Secondary transactions (Refunds)
979        if ( is_array( $crm_object_data['secondary_transactions'] ) ) {
980
981            foreach ( $crm_object_data['secondary_transactions'] as $sub_transaction ) {
982
983                // slightly modified version of above transaction insert logic.
984                $existing_transaction_id = $zbs->DAL->transactions->getTransaction( -1, $sub_transaction['existence_check_args'] );
985
986                // debug
987                if ( ! empty( $existing_transaction_id ) ) {
988                    $this->debug( 'Sub transaction: Existing transaction #' . $existing_transaction_id );
989                }
990
991                // build arguments
992                $args = array(
993                    'id'    => $existing_transaction_id,
994                    'owner' => -1,
995                    'data'  => $sub_transaction,
996                );
997
998                // if we have transaction id, also inject it as a parent (this gets caught by the UI to give a link back)
999                if ( isset( $transaction_id ) && ! empty( $contact_id ) ) {
1000                    $args['data']['parent'] = $transaction_id;
1001                }
1002
1003                // if we have contact id, also inject it
1004                if ( isset( $contact_id ) && ! empty( $contact_id ) ) {
1005                    $args['data']['contacts'] = array( $contact_id );
1006                }
1007
1008                // if we have company id, also inject it
1009                if ( isset( $company_id ) && ! empty( $company_id ) ) {
1010                    $args['data']['companies'] = array( $company_id );
1011                }
1012
1013                // if we have invoice_id, inject it
1014                // ... this makes our double entry invoices work.
1015                if ( isset( $invoice_id ) && ! empty( $invoice_id ) ) {
1016
1017                    $args['data']['invoice_id'] = $invoice_id;
1018
1019                }
1020
1021                // pass any extra meta along
1022                if ( isset( $sub_transaction['extra_meta'] ) && is_array( $sub_transaction['extra_meta'] ) ) {
1023
1024                    $args['extraMeta'] = $sub_transaction['extra_meta'];
1025                    unset( $args['data']['extra_meta'] );
1026
1027                }
1028
1029                $sub_transaction_id = $zbs->DAL->transactions->addUpdateTransaction( $args );
1030
1031                $this->debug( 'Added/Updated Sub-transaction (Refund) #' . $sub_transaction_id );
1032
1033            }
1034        }
1035
1036        return $transaction_id;
1037    }
1038
1039    /**
1040     * Translates a local store order into an import-ready crm objects array
1041     *  previously `tidy_order_from_store`
1042     *
1043     * @param $order_data
1044     * @param $order
1045     * @param $order_num
1046     * @param $order_items
1047     * @param $api
1048     * @param $order_tags
1049     * @param $origin
1050     * @param $extra_meta
1051     *
1052     * @return array of various objects (contact|company|transaction|invoice)
1053     */
1054    public function woocommerce_order_to_crm_objects(
1055        $order_data,
1056        $order,
1057        $order_post_id,
1058        $order_num,
1059        $order_items = '',
1060        $item_title = '',
1061        $from_api = false,
1062        $order_tags = array(),
1063        $origin = '',
1064        $extra_meta = array()
1065    ) {
1066
1067        global $zbs;
1068
1069        // get settings
1070        $settings = $this->settings();
1071
1072        // build arrays
1073        $data = array(
1074            'contact'                => array(),
1075            'contact_extra_meta'     => array(),
1076            'contact_logs'           => array(),
1077            'company'                => false,
1078            'invoice'                => false,
1079            'transaction'            => false,
1080            'secondary_transactions' => array(),
1081            'lineitems'              => array(),
1082            'order_post_id'          => $order_post_id,
1083        );
1084
1085        // Below we sometimes need to do some type-conversion, (e.g. dates), so here we retrieve our
1086        // crm contact custom fields to use the types...
1087        $custom_fields             = $zbs->DAL->getActiveCustomFields( array( 'objtypeid' => ZBS_TYPE_CONTACT ) );
1088        $is_status_mapping_enabled = ( isset( $settings['enable_woo_status_mapping'] ) ? ( (int) $settings['enable_woo_status_mapping'] === 1 ) : true );
1089        $contact_statuses          = zeroBSCRM_getCustomerStatuses( true );
1090
1091        // initialise dates
1092        $contact_creation_date         = -1;
1093        $contact_creation_date_uts     = -1;
1094        $transaction_creation_date_uts = -1;
1095        $invoice_creation_date_uts     = -1;
1096
1097        // Tag customer setting i.e. do we want to tag with every product name
1098        // Will be useful to be able to filter Sales Dashboard by Product name eventually
1099        $tag_contact_with_item     = false;
1100        $tag_transaction_with_item = false;
1101        $tag_invoice_with_item     = false;
1102        $tag_with_coupon           = false;
1103        $tag_product_prefix        = ( isset( $settings['wctagproductprefix'] ) ) ? zeroBSCRM_textExpose( $settings['wctagproductprefix'] ) : '';
1104        $tag_coupon_prefix         = ( isset( $settings['wctagcouponprefix'] ) ) ? zeroBSCRM_textExpose( $settings['wctagcouponprefix'] ) : '';
1105        if ( isset( $settings['wctagcust'] ) && $settings['wctagcust'] == 1 ) {
1106
1107            $tag_contact_with_item = true;
1108
1109        }
1110        if ( isset( $settings['wctagtransaction'] ) && $settings['wctagtransaction'] == 1 ) {
1111
1112            $tag_transaction_with_item = true;
1113
1114        }
1115        if ( isset( $settings['wctaginvoice'] ) && $settings['wctaginvoice'] == 1 ) {
1116
1117            $tag_invoice_with_item = true;
1118
1119        }
1120        if ( isset( $settings['wctagcoupon'] ) && $settings['wctagcoupon'] == 1 ) {
1121
1122            $tag_with_coupon = true;
1123
1124        }
1125
1126        // pre-processing from the $order_data
1127        $order_status   = $order_data['status'];
1128        $order_currency = $order_data['currency'];
1129
1130        // Add external source
1131        $data['source'] = array(
1132            'externalSource'    => 'woo',
1133            'externalSourceUID' => $order_post_id,
1134            'origin'            => $origin,
1135            'onlyID'            => true,
1136        );
1137
1138        // Dates:
1139        if ( ! $from_api ) {
1140
1141            // from local store
1142
1143            if ( isset( $order_data['date_created'] ) && ! empty( $order_data['date_created'] ) ) {
1144
1145                $contact_creation_date         = $order_data['date_created']->date( 'Y-m-d h:m:s' );
1146                $contact_creation_date_uts     = $order_data['date_created']->date( 'U' );
1147                $transaction_creation_date_uts = $order_data['date_created']->date( 'U' );
1148                $invoice_creation_date_uts     = $order_data['date_created']->date( 'U' );
1149
1150            }
1151        } else {
1152
1153            // from API
1154            // dates are strings in API.
1155            $contact_creation_date         = $order_data['date_created'];
1156            $contact_creation_date_uts     = strtotime( $order_data['date_created'] );
1157            $transaction_creation_date_uts = strtotime( $order_data['date_created'] );
1158            $invoice_creation_date_uts     = strtotime( $order_data['date_created'] );
1159
1160        }
1161
1162        // ==== Tax Rates (on local stores only)
1163        if ( ! $from_api ) {
1164
1165            // Force Woo order totals recalculation to ensure taxes were applied correctly
1166            // Only force recalculation if the order is not paid yet
1167            if ( $order->get_status() === 'pending' || $order->get_status() === 'on-hold' ) {
1168                try {
1169                    $order->calculate_totals();
1170                    $order->save();
1171                } catch ( \TypeError $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
1172
1173                    /*
1174                    This is a Woo bug with empty fees: https://github.com/woocommerce/woocommerce/issues/44859
1175                    For now we'll just ignore it and carry on.
1176                    */
1177                }
1178            }
1179
1180            $order_data = $order->get_data();
1181
1182            // retrieve tax table to feed in tax links
1183            $tax_rates_table = $this->woosync()->background_sync->get_tax_rates_table();
1184
1185            // Add/update any tax rates used in this order
1186            $tax_rate_changes = false;
1187            foreach ( $order->get_items( 'tax' ) as $item ) {
1188
1189                $tax_rate_id = $item->get_rate_id(); // Tax rate ID
1190                $tax_label   = $item->get_label(); // Tax label name
1191                $tax_percent = \WC_Tax::get_rate_percent( $tax_rate_id ); // Tax percentage
1192                $tax_rate    = str_replace( '%', '', $tax_percent ); // Tax rate
1193
1194                /*
1195                $tax_rate_code  = $item->get_rate_code(); // Tax code
1196                $tax_name       = $item->get_(); // Tax name
1197                $tax_total      = $item->get_tax_total(); // Tax Total
1198                $tax_ship_total = $item->get_shipping_tax_total(); // Tax shipping total
1199                $tax_compound   = $item->get_compound(); // Tax compound
1200                */
1201
1202                // check if tax rate exists already
1203                $tax_rate_exists = false;
1204                foreach ( $tax_rates_table as $tax_rate_id => $tax_rate_detail ) {
1205
1206                    if ( // name
1207                        $tax_label === $tax_rate_detail['name']
1208                        &&
1209                        // rate - compare with full precision to preserve accuracy
1210                        (float) $tax_rate === (float) $tax_rate_detail['rate']
1211
1212                        ) {
1213
1214                            $tax_rate_exists = true;
1215                            break;
1216
1217                    }
1218                }
1219
1220                // add/update it if it doesn't exist or has changed rate
1221                if ( ! $tax_rate_exists ) {
1222
1223                    // add/update
1224                    $added_rate_id = zeroBSCRM_taxRates_addUpdateTaxRate(
1225                        array(
1226
1227                            // 'id'   => -1,
1228                            'data' => array(
1229                                'name' => $tax_label,
1230                                'rate' => (float) $tax_rate,
1231                            ),
1232                        )
1233                    );
1234
1235                    // mark as table changed
1236                    $tax_rate_changes = true;
1237
1238                }
1239            }
1240
1241            // reload tax rate table if changes actioned
1242            if ( $tax_rate_changes ) {
1243
1244                $tax_rates_table = $this->woosync()->background_sync->get_tax_rates_table( true );
1245
1246            }
1247        }
1248
1249        // /=== Tax
1250
1251        // ==== Contact
1252
1253        // Always use contact email, not billing email:
1254        // We've hit issues based on adding a Jetpack CRM contact based on billing email if they have a WP user attached
1255        // with a different email. The $order_data['customer_id'] will = 0 for guest or +tive for users. This way we will always
1256        // store the contact against the contact email (and not the billing email)
1257        $contact_email = '';
1258        $billing_email = '';
1259
1260        if ( isset( $order_data['customer_id'] ) && $order_data['customer_id'] > 0 ) {
1261            // then we have an existing user. Get the WP email
1262                $user = get_user_by( 'id', $order_data['customer_id'] );
1263            if ( $user ) {
1264                $contact_email = $user->user_email;
1265            }
1266            if ( isset( $order_data['billing']['email'] ) ) {
1267                $billing_email = $order_data['billing']['email'];
1268            }
1269
1270            // pass WP ID to contact
1271            $data['contact']['wpid'] = $order_data['customer_id'];
1272        } elseif ( isset( $order_data['billing']['email'] ) ) {
1273            $billing_email = $order_data['billing']['email'];
1274            $contact_email = $billing_email;
1275        }
1276
1277        // we only add a contact whom has an email
1278        if ( ! empty( $contact_email ) ) {
1279
1280            if ( $is_status_mapping_enabled ) {
1281                $contact_id = zeroBS_getCustomerIDWithEmail( $contact_email );
1282                // If this is a new contact or the current status equals the first status (CRM's default value is 'Lead'), we are allowed to change it.
1283                if ( empty( $contact_id ) || $zbs->DAL->contacts->getContactStatus( $contact_id ) === $contact_statuses[0] ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
1284                    $data['contact']['status'] = $this->woosync()->translate_order_status_to_obj_status( ZBS_TYPE_CONTACT, $order_status );
1285                }
1286            }
1287            $data['contact']['created']         = $contact_creation_date_uts;
1288            $data['contact']['email']           = $contact_email;
1289            $data['contact']['externalSources'] = array(
1290                array(
1291                    'source' => 'woo',
1292                    'uid'    => $order_post_id,
1293                    'origin' => $origin,
1294                    'owner'  => 0, // for now we hard-type no owner to avoid ownership issues. As we roll out fuller ownership we may want to adapt this.
1295                ),
1296            );
1297
1298            if ( isset( $order_data['billing']['first_name'] ) ) {
1299                $data['contact']['fname'] = $order_data['billing']['first_name'];
1300            }
1301
1302            if ( isset( $order_data['billing']['last_name'] ) ) {
1303                $data['contact']['lname'] = $order_data['billing']['last_name'];
1304            }
1305
1306            // if we've not got any fname/lname and we do have 'customer_id' attribute (wp user id)
1307            // ... check the wp user to see if they have a display name we can use.
1308            if ( isset( $order_data['customer_id'] ) && $order_data['customer_id'] > 0 ) {
1309
1310                // retrieve wp user
1311                $woo_customer_meta = get_user_meta( $order_data['customer_id'] );
1312
1313                // fname
1314                if (
1315                    isset( $woo_customer_meta['first_name'] )
1316                    &&
1317                    ( ! isset( $data['contact']['fname'] ) || empty( $data['contact']['fname'] ) )
1318                ) {
1319
1320                    $data['contact']['fname'] = $woo_customer_meta['first_name'][0];
1321
1322                }
1323
1324                // lname
1325                if (
1326                    isset( $woo_customer_meta['last_name'] )
1327                    &&
1328                    ( ! isset( $data['contact']['lname'] ) || empty( $data['contact']['lname'] ) )
1329                ) {
1330
1331                    $data['contact']['lname'] = $woo_customer_meta['last_name'][0];
1332
1333                }
1334            }
1335
1336            if ( isset( $order_data['billing']['address_1'] ) ) {
1337                $data['contact']['addr1'] = $order_data['billing']['address_1'];
1338            }
1339
1340            if ( isset( $order_data['billing']['address_2'] ) ) {
1341                $data['contact']['addr2'] = $order_data['billing']['address_2'];
1342            }
1343
1344            if ( isset( $order_data['billing']['city'] ) ) {
1345                $data['contact']['city'] = $order_data['billing']['city'];
1346            }
1347
1348            if ( isset( $order_data['billing']['state'] ) ) {
1349                $data['contact']['county'] = $order_data['billing']['state'];
1350            }
1351
1352            if ( isset( $order_data['billing']['postcode'] ) ) {
1353                $data['contact']['postcode'] = $order_data['billing']['postcode'];
1354            }
1355
1356            if ( isset( $order_data['billing']['country'] ) ) {
1357                $data['contact']['country'] = $order_data['billing']['country'];
1358            }
1359
1360            if ( isset( $order_data['billing']['phone'] ) ) {
1361                $data['contact']['hometel'] = $order_data['billing']['phone'];
1362            }
1363
1364            // if setting: copy shipping address
1365            if ( $settings['wccopyship'] ) {
1366                if ( isset( $order_data['shipping']['address_1'] ) ) {
1367                    $data['contact']['secaddr1'] = $order_data['shipping']['address_1'];
1368                }
1369
1370                if ( isset( $order_data['shipping']['address_2'] ) ) {
1371                    $data['contact']['secaddr2'] = $order_data['shipping']['address_2'];
1372                }
1373
1374                if ( isset( $order_data['shipping']['city'] ) ) {
1375                    $data['contact']['seccity'] = $order_data['shipping']['city'];
1376                }
1377
1378                if ( isset( $order_data['shipping']['state'] ) ) {
1379                    $data['contact']['seccounty'] = $order_data['shipping']['state'];
1380                }
1381
1382                if ( isset( $order_data['shipping']['postcode'] ) ) {
1383                    $data['contact']['secpostcode'] = $order_data['shipping']['postcode'];
1384                }
1385
1386                if ( isset( $order_data['shipping']['country'] ) ) {
1387                    $data['contact']['seccountry'] = $order_data['shipping']['country'];
1388                }
1389            }
1390
1391            // Store the billing email as an alias, and as an extraMeta (for later potential origin work)
1392            if ( ! empty( $billing_email ) ) {
1393
1394                $data['contact_extra_meta']['billingemail'] = $billing_email;
1395
1396                // we only need to add the alias if it's different to the $contact_email
1397                if ( $billing_email !== $contact_email ) {
1398                    $data['contact']['aliases'] = array( $billing_email );
1399                }
1400            }
1401
1402            // Store any customer notes
1403            if ( isset( $order_data['customer_note'] ) && ! empty( $order_data['customer_note'] ) ) {
1404
1405                // Previously `notes` field, refactor into core moved this into log addition
1406                $data['contact_logs'][] = array(
1407
1408                    'type'      => 'note',
1409                    'shortdesc' => __( 'WooCommerce Customer notes', 'zero-bs-crm' ),
1410                    'longdesc'  => __( 'WooCommerce Customer notes:', 'zero-bs-crm' ) . ' ' . $order_data['customer_note'] . '<br>' . sprintf( __( 'From order: #%s', 'zero-bs-crm' ), $order_post_id ),
1411
1412                );
1413
1414            }
1415
1416            // Retrieve any WooCommerce Checkout metadata & try to store it against contact if match custom fields
1417            // Returns array of WC_Meta_Data objects https://woocommerce.github.io/code-reference/classes/WC-Meta-Data.html
1418            // Filters to support WooCommerce Checkout Field Editor, Field editor Pro etc.
1419            /*
1420                    [1] => WC_Meta_Data Object
1421                        (
1422                                [current_data:protected] => Array
1423                                        (
1424                                                [id] => 864
1425                                                [key] => tax-id
1426                                                [value] => 12345
1427                                        )
1428
1429                                [data:protected] => Array
1430                                        (
1431                                                [id] => 864
1432                                                [key] => tax-id
1433                                                [value] => 12345
1434                                        )
1435
1436                        )
1437
1438                */
1439            if ( isset( $order_data['meta_data'] ) && is_array( $order_data['meta_data'] ) ) {
1440
1441                // Cycle through them and pick out matching fields
1442                foreach ( $order_data['meta_data'] as $wc_meta_data_object ) {
1443
1444                    // retrieve data
1445                    $meta_data = $wc_meta_data_object->get_data();
1446
1447                    if ( is_array( $meta_data ) ) {
1448
1449                        // process it, only adding if not already set (to avoid custom checkout overriding base fields)
1450                        $key = $zbs->DAL->makeSlug( $meta_data['key'] );
1451
1452                        if ( ! empty( $key ) && ! isset( $data['contact'][ $key ] ) ) {
1453
1454                            $value = $meta_data['value'];
1455
1456                            // see if we have a matching custom field to infer type conversions from:
1457                            if ( isset( $custom_fields[ $key ] ) ) {
1458
1459                                // switch on type
1460                                switch ( $custom_fields[ $key ][0] ) {
1461
1462                                    case 'date':
1463                                        // May 29, 2022 => UTS
1464                                        $value = strtotime( $value );
1465                                        break;
1466
1467                                }
1468                            }
1469
1470                            // simplistic add
1471                            $data['contact'][ $key ] = $value;
1472
1473                            // filter through any mods
1474                            $data['contact'] = $this->filter_checkout_contact_fields( $key, $value, $data['contact'], $order, $custom_fields );
1475
1476                        }
1477                    }
1478                }
1479            }
1480
1481            // WooCommerce Checkout Add-ons fields support, where installed
1482            $data['contact'] = $this->checkout_add_ons_add_field_values( $order_post_id, $data['contact'], $custom_fields );
1483
1484        }
1485
1486        // ==== Company (where available)
1487
1488        if ( isset( $order_data['billing'] ) && isset( $order_data['billing']['company'] ) ) {
1489
1490            /**
1491             * Build fields for company in case it doesn't exist
1492             *
1493             * WooCommerce only gives us one company field (company name), and we can't infer other fields
1494             * from customer info since multiple customers might put the same company
1495             */
1496            $data['company'] = array(
1497                'status'          => __( 'Customer', 'zero-bs-crm' ),
1498                'name'            => $order_data['billing']['company'],
1499                'created'         => $contact_creation_date_uts,
1500                'externalSources' => array(
1501                    array(
1502                        'source' => 'woo',
1503                        'uid'    => $order_post_id,
1504                        'origin' => $origin,
1505                        'owner'  => 0, // for now we hard-type no owner to avoid ownership issues. As we roll out fuller ownership we may want to adapt this.
1506                    ),
1507                ),
1508            );
1509
1510        }
1511
1512        // ==== Transaction
1513
1514        // prep dates
1515        $transaction_paid_date_uts      = null;
1516        $transaction_completed_date_uts = null;
1517
1518        if ( array_key_exists( 'date_paid', $order_data ) && ! empty( $order_data['date_paid'] ) ) {
1519            $transaction_paid_date_uts = $order_data['date_paid']->date( 'U' );
1520        }
1521
1522        $invoice_status = $this->woosync()->translate_order_status_to_obj_status( ZBS_TYPE_INVOICE, $order_status );
1523
1524        // retrieve completed date, where available
1525        if ( array_key_exists( 'date_completed', $order_data ) && ! empty( $order_data['date_completed'] ) ) {
1526
1527            $transaction_completed_date_uts = $order_data['date_completed']->date( 'U' );
1528
1529        }
1530
1531        // Retrieve and process order line items
1532        if ( ! $from_api ) {
1533
1534            $item_title  = '';
1535            $order_items = $order->get_items();
1536
1537            // Retrieve order-used tax rates
1538            $tax_items_labels = array();
1539
1540            foreach ( $order->get_items( 'tax' ) as $tax_item ) {
1541                $rate_id                      = $tax_item->get_rate_id();
1542                $tax_items_labels[ $rate_id ] = $tax_item->get_label();
1543
1544                if ( isset( $tax_items_labels[ $rate_id ] ) && ! empty( $tax_item->get_shipping_tax_total() ) ) {
1545
1546                    $tax_label = $tax_items_labels[ $rate_id ];
1547
1548                    foreach ( $tax_rates_table as $tax_rate_id => $tax_rate_detail ) {
1549                        if ( $tax_label === $tax_rate_detail['name'] ) {
1550                            $shipping_tax_id = $tax_rate_id;
1551                            break;
1552                        }
1553                    }
1554                }
1555            }
1556
1557            $order_data['subtotal'] = 0.0;
1558
1559            $item_tax_rate_ids = null;
1560
1561            // cycle through order items to create crm line items
1562            foreach ( $order_items as $item_key => $item ) {
1563
1564                // first item gets item name
1565                if ( empty( $item_title ) ) {
1566
1567                    $item_title = $item->get_name();
1568
1569                } else {
1570
1571                    $item_title = __( 'Multiple Items', 'zero-bs-crm' );
1572
1573                }
1574
1575                // retrieve item data
1576                $item_data = $item->get_data();
1577
1578                // catch cases where quantity is 0; see gh-2190
1579                $price = empty( $item_data['quantity'] ) ? 0 : $item_data['subtotal'] / $item_data['quantity'];
1580
1581                $order_data['subtotal'] += $price;
1582
1583                // translate Woo taxes to CRM taxes
1584                $item_woo_taxes    = $item->get_taxes();
1585                $tax_label         = '';
1586                $item_tax_rate_ids = array(); // collect taxes
1587
1588                foreach ( $item_woo_taxes['subtotal'] as $rate_id => $tax ) {
1589
1590                    if ( isset( $tax_items_labels[ $rate_id ] ) ) {
1591
1592                        $tax_label = $tax_items_labels[ $rate_id ];
1593
1594                        // match tax label to tax in our crm tax table (should have been added by the logic above here, even if new)
1595                        foreach ( $tax_rates_table as $tax_rate_id => $tax_rate_detail ) {
1596
1597                            if ( $tax_label === $tax_rate_detail['name'] ) {
1598
1599                                // this tax is applied to this line item
1600                                $item_tax_rate_ids[] = $tax_rate_id;
1601
1602                            }
1603                        }
1604                    }
1605                }
1606
1607                // Get product short description for line item description.
1608                $product_id_to_fetch = $item_data['product_id'];
1609                if ( isset( $item_data['variation_id'] ) && $item_data['variation_id'] > 0 ) {
1610                    $product_id_to_fetch = $item_data['variation_id'];
1611                }
1612                $product               = wc_get_product( $product_id_to_fetch );
1613                $line_item_description = $product ? $product->get_short_description() : '';
1614                // If short description is empty or product not found, fall back to the product name.
1615                if ( empty( $line_item_description ) ) {
1616                    $line_item_description = $item_data['name'];
1617                }
1618
1619                // attributes not yet translatable but originally referenced: `variation_id|tax_class|subtotal_tax`
1620                $new_line_item = array(
1621                    'order'    => $order_post_id, // passed as parameter to this function
1622                    'currency' => $order_currency,
1623                    'quantity' => $item_data['quantity'],
1624                    'price'    => $price,
1625                    'total'    => $item_data['total'],
1626                    'title'    => $item_data['name'],
1627                    'desc'     => $line_item_description,
1628                    'tax'      => $item_data['total_tax'],
1629                    'shipping' => 0,
1630                );
1631
1632                // add taxes, where present
1633                if ( is_array( $item_tax_rate_ids ) && count( $item_tax_rate_ids ) > 0 ) {
1634                    $new_line_item['taxes'] = implode( ',', $item_tax_rate_ids );
1635                }
1636
1637                // Add order item line
1638                $data['lineitems'][] = $new_line_item;
1639
1640                // add to tags where not alreday present
1641                if ( ! in_array( $item_data['name'], $order_tags ) ) {
1642                    $order_tags[] = $tag_product_prefix . $item_data['name'];
1643                }
1644            }
1645
1646            // --- Process any present fee in the order --- //
1647            $fees = $order->get_fees();
1648
1649            if ( is_array( $fees ) && count( $fees ) > 0 ) {
1650                foreach ( $fees as $fee ) {
1651                    if ( $fee instanceof \WC_Order_Item_Fee ) {
1652
1653                        $value = $fee->get_amount( false );
1654
1655                        // Woo allows a fee's value to be an empty string, so account for that to prevent a PHP fatal.
1656                        if ( empty( $value ) ) {
1657                            $value = 0;
1658                        }
1659
1660                        $new_line_item = array(
1661                            'order'    => $order_post_id, // passed as parameter to this function
1662                            'currency' => $order_currency,
1663                            'quantity' => 1,
1664                            'price'    => $value,
1665                            'fee'      => $value,
1666                            'total'    => $value,
1667                            'title'    => esc_html__( 'Fee', 'zero-bs-crm' ),
1668                            'desc'     => $fee->get_name(),
1669                            'tax'      => $fee->get_total_tax(),
1670                            'taxes'    => -1,
1671                            'shipping' => 0.0,
1672                        );
1673
1674                        // Apply the same tax
1675                        if ( is_array( $item_tax_rate_ids ) && count( $item_tax_rate_ids ) > 0 ) {
1676                            $new_line_item['taxes'] = implode( ',', $item_tax_rate_ids );
1677                        }
1678
1679                        $order_data['subtotal'] += floatval( $new_line_item['price'] );
1680
1681                        // Add fee as an item to the invoice
1682                        $data['lineitems'][] = $new_line_item;
1683                    }
1684                }
1685            }
1686
1687            // if the order has a coupon. Tag the contact with that coupon too, but only if from same store.
1688            if ( $tag_with_coupon ) {
1689
1690                foreach ( $order->get_coupon_codes() as $coupon_code ) {
1691                    $order_tags[] = $tag_coupon_prefix . $coupon_code;
1692                }
1693            }
1694        } else {
1695
1696            // API response returns these differently
1697            $data['lineitems'] = $order_items;
1698
1699        }
1700
1701        // tags (contact)
1702        if ( $tag_contact_with_item ) {
1703
1704            $data['contact']['tags'] = $order_tags;
1705
1706        }
1707
1708        $transaction_status = $this->woosync()->translate_order_status_to_obj_status( ZBS_TYPE_TRANSACTION, $order_status );
1709
1710        if ( $transaction_status !== JPCRM_WOOSYNC_DO_NOT_CREATE['id'] ) {
1711            // fill out transaction header (object)
1712            $data['transaction'] = array(
1713
1714                'ref'                  => $order_num,
1715                'type'                 => __( 'Sale', 'zero-bs-crm' ),
1716                'title'                => $item_title,
1717                'status'               => $transaction_status,
1718                'total'                => $order_data['total'],
1719                'date'                 => $transaction_creation_date_uts,
1720                'created'              => $transaction_creation_date_uts,
1721                'date_completed'       => $transaction_completed_date_uts,
1722                'date_paid'            => $transaction_paid_date_uts,
1723                'externalSources'      => array(
1724                    array(
1725                        'source' => 'woo',
1726                        'uid'    => $order_post_id,
1727                        'origin' => $origin,
1728                        'owner'  => 0, // for now we hard-type no owner to avoid ownership issues. As we roll out fuller ownership we may want to adapt this.
1729                    ),
1730                ),
1731                'currency'             => $order_currency,
1732                'net'                  => ( (float) $order_data['total'] - (float) $order_data['discount_total'] - (float) $order_data['total_tax'] - (float) $order_data['shipping_total'] ),
1733                'tax'                  => $order_data['total_tax'],
1734                'fee'                  => 0,
1735                'discount'             => $order_data['discount_total'],
1736                'shipping'             => $order_data['shipping_total'],
1737                'existence_check_args' => $data['source'],
1738                'lineitems'            => $data['lineitems'],
1739
1740            );
1741
1742            // tags (transaction)
1743            if ( $tag_transaction_with_item ) {
1744
1745                $data['transaction']['tags']     = $order_tags;
1746                $data['transaction']['tag_mode'] = 'append';
1747
1748            }
1749
1750            // any extra meta?
1751            if ( is_array( $extra_meta ) && count( $extra_meta ) > 0 ) {
1752
1753                $data['transaction_extra_meta'] = $extra_meta;
1754
1755            }
1756
1757            // Sub-transactions (refunds)
1758            if ( method_exists( $order, 'get_refunds' ) ) {
1759
1760                // process refunds
1761                $refunds = $order->get_refunds();
1762                if ( is_array( $refunds ) ) {
1763
1764                    // cycle through and add as secondary transactions
1765                    foreach ( $refunds as $refund ) {
1766
1767                        // retrieve refund data
1768                        $refund_data = $refund->get_data();
1769
1770                        // process the refund as a secondary transaction
1771                        // This mimicks the main transaction, taking from the refund object where sensible
1772                        $refund_id = $refund->get_id();
1773                        // translators: %s is the order number from WooCommerce.
1774                        $refund_title       = sprintf( __( 'Refund against transaction #%s', 'zero-bs-crm' ), $order_num );
1775                        $refund_description = $refund_title . "\r\n" . __( 'Reason: ', 'zero-bs-crm' ) . $refund_data['reason'];
1776                        $refund_date_uts    = strtotime( $refund_data['date_created']->__toString() );
1777                        if ( isset( $refund_data['currency'] ) && ! empty( $refund_data['currency'] ) ) {
1778                            $refund_currency = $refund_data['currency'];
1779                        } else {
1780                            $refund_currency = $order_currency;
1781                        }
1782
1783                        $refund_transaction = array(
1784
1785                            'ref'                  => $refund_id,
1786                            'type'                 => __( 'Refund', 'zero-bs-crm' ),
1787                            'title'                => $refund_title,
1788                            'status'               => __( 'Refunded', 'zero-bs-crm' ),
1789                            'total'                => -$refund_data['total'],
1790                            'desc'                 => $refund_description,
1791                            'date'                 => $refund_date_uts,
1792                            'created'              => $refund_date_uts,
1793                            'date_completed'       => $transaction_completed_date_uts,
1794                            'date_paid'            => $transaction_paid_date_uts,
1795                            'externalSources'      => array(
1796                                array(
1797                                    'source' => 'woo',
1798                                    'uid'    => $refund_id, // rather than order_num, here we use the refund item id
1799                                    'origin' => $origin,
1800                                    'owner'  => 0, // for now we hard-type no owner to avoid ownership issues. As we roll out fuller ownership we may want to adapt this.
1801                                ),
1802                            ),
1803                            'currency'             => $refund_currency,
1804                            'net'                  => -( (float) $refund_data['total'] - (float) $refund_data['discount_total'] - (float) $refund_data['total_tax'] - (float) $refund_data['shipping_total'] ),
1805                            'tax'                  => $refund_data['total_tax'],
1806                            'fee'                  => 0,
1807                            'discount'             => $refund_data['discount_total'],
1808                            'shipping'             => $refund_data['shipping_total'],
1809                            'existence_check_args' => array(
1810                                'externalSource'    => 'woo',
1811                                'externalSourceUID' => $refund_id,
1812                                'origin'            => $origin,
1813                                'onlyID'            => true,
1814                            ),
1815                            'lineitems'            => array(
1816                                // here we roll a single refund line item
1817                                array(
1818                                    'order'    => $refund_id,
1819                                    'currency' => $refund_currency,
1820                                    'quantity' => 1,
1821                                    'price'    => -$refund_data['total'],
1822                                    'total'    => -$refund_data['total'],
1823                                    'title'    => $refund_title,
1824                                    'desc'     => $refund_description,
1825                                    'tax'      => $refund_data['total_tax'],
1826                                    'shipping' => 0,
1827                                ),
1828                            ),
1829                            'extra_meta'           => array(), // this is caught to insert as extraMeta
1830
1831                        );
1832
1833                        // Add any extra meta we can glean in case future useful:
1834                        $refund_transaction['extra_meta']['order_num'] = $order_num; // backtrace
1835                        if ( isset( $refund_data['refunded_by'] ) && ! empty( $refund_data['refunded_by'] ) ) {
1836                            $refund_transaction['extra_meta']['refunded_by'] = $refund_data['refunded_by'];
1837                        }
1838                        if ( isset( $refund_data['refunded_payment'] ) && ! empty( $refund_data['refunded_payment'] ) ) {
1839                            $refund_transaction['extra_meta']['refunded_payment'] = $refund_data['refunded_payment'];
1840                        }
1841
1842                        // add it to the stack
1843                        $data['secondary_transactions'][] = $refund_transaction;
1844                    }
1845                }
1846            }
1847        }
1848
1849        // ==== Invoice
1850        $data['invoice'] = array();
1851        if ( $settings['wcinv'] == 1 ) {
1852            $data['invoice'] = array(
1853                'woo_use_crm_id'       => ! empty( $settings['wccrminvreference'] ),
1854                'id_override'          => 'woo-' . $order_num, // (ignored if wccrminvreference === 1) We have to add a prefix here otherwise woo order #123 wouldn't insert if invoice with id #123 already exists.
1855                'status'               => $invoice_status,
1856                'currency'             => $order_currency,
1857                'date'                 => $invoice_creation_date_uts,
1858                'due_date'             => $invoice_creation_date_uts,
1859                'net'                  => $order_data['subtotal'],
1860                'total'                => $order_data['total'],
1861                'discount'             => $order_data['discount_total'],
1862                'discount_type'        => 'm',
1863                'shipping'             => $order_data['shipping_total'],
1864                'shipping_tax'         => $order_data['shipping_tax'],
1865                'tax'                  => $order_data['total_tax'],
1866                'ref'                  => $item_title,
1867                'hours_or_quantity'    => 1,
1868                'lineitems'            => $data['lineitems'],
1869                'created'              => $invoice_creation_date_uts,
1870                'externalSources'      => array(
1871                    array(
1872                        'source' => 'woo',
1873                        'uid'    => $order_post_id,
1874                        'origin' => $origin,
1875                        'owner'  => 0, // for now we hard-type no owner to avoid ownership issues. As we roll out fuller ownership we may want to adapt this.
1876                    ),
1877                ),
1878                'existence_check_args' => $data['source'],
1879                'extra_meta'           => array(
1880                    'order_post_id' => $order_post_id,
1881                    'api'           => $from_api,
1882                ),
1883            );
1884
1885            if ( isset( $shipping_tax_id ) && ! empty( $shipping_tax_id ) ) {
1886                $data['invoice']['shipping_taxes'] = $shipping_tax_id;
1887            }
1888            if ( isset( $data['invoice']['tax'] ) && isset( $order_data['discount_tax'] ) ) {
1889                $data['invoice']['tax'] -= $order_data['discount_tax'];
1890            }
1891
1892            if ( is_array( $extra_meta ) && count( $extra_meta ) > 0 ) {
1893
1894                $data['invoice']['extra_meta'] = array_merge( $extra_meta, $data['invoice']['extra_meta'] );
1895
1896            }
1897
1898            // tags (invoice)
1899            if ( $tag_invoice_with_item ) {
1900
1901                $data['invoice']['tags']     = $order_tags;
1902                $data['invoice']['tag_mode'] = 'append';
1903
1904            }
1905        }
1906
1907        // Let third parties modify the data array before it's stored as a CRM Object.
1908        // This will allow totals to be updated when WooSync pulls data from a store in a different currency.
1909        return apply_filters( 'jpcrm_woo_sync_order_data', $data );
1910    }
1911
1912    /**
1913     * Translates an API order into an import-ready crm objects array
1914     *  previously `tidy_order_from_api`
1915     *
1916     * @param $order
1917     *
1918     * @return array of various objects (contact|company|transaction|invoice)
1919     */
1920    public function woocommerce_api_order_to_crm_objects( $order, $origin = '' ) {
1921
1922        // $order_status is the WooCommerce order status
1923        $settings           = $this->settings();
1924        $tag_with_coupon    = false;
1925        $tag_product_prefix = ( isset( $settings['wctagproductprefix'] ) ) ? zeroBSCRM_textExpose( $settings['wctagproductprefix'] ) : '';
1926        $tag_coupon_prefix  = zeroBSCRM_textExpose( $settings['wctagcouponprefix'] );
1927        if ( $settings['wctagcoupon'] == 1 ) {
1928
1929            $tag_with_coupon = true;
1930
1931        }
1932
1933        // Translate API order into local order equivalent
1934        $order_data = array(
1935
1936            'status'         => $order->status,
1937            'currency'       => $order->currency,
1938            'date_created'   => $order->date_created_gmt,
1939            'customer_id'    => 0, // will be 0 from the API.
1940            'billing'        => array(
1941                'company'    => $order->billing->company,
1942                'email'      => $order->billing->email,
1943                'first_name' => $order->billing->first_name,
1944                'last_name'  => $order->billing->last_name,
1945                'address_1'  => $order->billing->address_1,
1946                'address_2'  => $order->billing->address_2,
1947                'city'       => $order->billing->city,
1948                'state'      => $order->billing->state,
1949                'postcode'   => $order->billing->postcode,
1950                'country'    => $order->billing->country,
1951                'phone'      => $order->billing->phone,
1952            ),
1953            'shipping'       => array(
1954                'address_1' => $order->shipping->address_1,
1955                'address_2' => $order->shipping->address_2,
1956                'city'      => $order->shipping->city,
1957                'state'     => $order->shipping->state,
1958                'postcode'  => $order->shipping->postcode,
1959                'country'   => $order->shipping->country,
1960            ),
1961            'total'          => $order->total,
1962            'discount_total' => $order->discount_total,
1963            'shipping_total' => $order->shipping_total,
1964            'shipping_tax'   => $order->shipping_tax,
1965            'total_tax'      => $order->total_tax,
1966
1967        );
1968
1969        $order_line_items = array();
1970        $order_tags       = array();
1971        $item_title       = '';
1972
1973        // cycle through line items and process
1974        foreach ( $order->line_items as $line_item_key => $line_item ) {
1975
1976            if ( empty( $item_title ) ) {
1977
1978                $item_title = $line_item->name;
1979
1980            } else {
1981
1982                $item_title = __( 'Multiple Items', 'zero-bs-crm' );
1983
1984            }
1985
1986            $order_line_items[] = array(
1987                'order'    => $order->id,
1988                'quantity' => $line_item->quantity,
1989                'price'    => $line_item->price,
1990                'currency' => $order_data['currency'],
1991                'total'    => $line_item->subtotal,
1992                'title'    => $line_item->name,
1993                'desc'     => $line_item->name . ' (#' . $line_item->product_id . ')',
1994                'tax'      => $line_item->total_tax,
1995                'shipping' => 0,
1996            );
1997
1998            if ( ! in_array( $line_item->name, $order_tags ) ) {
1999
2000                $order_tags[] = $tag_product_prefix . $line_item->name;
2001
2002            }
2003        }
2004
2005        // catch coupon_lines and tag if tagging
2006        // http://woocommerce.github.io/woocommerce-rest-api-docs/#coupon-properties
2007        if ( $tag_with_coupon && isset( $order->coupon_lines ) ) {
2008
2009            foreach ( $order->coupon_lines as $coupon_line ) {
2010
2011                $order_tags[] = $tag_coupon_prefix . $coupon_line->code;
2012
2013            }
2014        }
2015
2016            // store the order post ID for future reference
2017        $extra_meta = array(
2018            'order_num' => $order->number,
2019        );
2020
2021        // Finally translate through `woocommerce_order_to_crm_objects` with the argument `$from_api = true` so it skips local store parts of the process
2022        return $this->woocommerce_order_to_crm_objects(
2023            $order_data,
2024            $order,
2025            $order->id,
2026            $order->number,
2027            $order_line_items,
2028            $item_title,
2029            true,
2030            $order_tags,
2031            $origin,
2032            $extra_meta
2033        );
2034    }
2035
2036    /**
2037     * Attempts to return the percentage completed of a sync
2038     *
2039     * @param bool $return_counts - Return counts (if true returns an array inc % completed, x of y pages)
2040     * @param bool $use_cache - use values cached in object instead of retrieving them directly from Woo
2041     *
2042     * @return int|bool - percentage completed, or false if not attainable
2043     */
2044    public function percentage_completed( $return_counts = false, $use_cache = true ) {
2045
2046        // if not using cache, retrieve values from Woo
2047        if ( ! $use_cache ) {
2048
2049            // could probably abstract the retrieval of orders for more nesting. For now it's fairly DRY as only in 2 places.
2050
2051            // store/api switch
2052            if ( $this->import_mode( $this->site_key ) == JPCRM_WOO_SYNC_MODE_API ) {
2053
2054                // API
2055                try {
2056
2057                    // get client
2058                    $woocommerce = $this->woosync()->get_woocommerce_client( $this->site_key );
2059
2060                    // retrieve orders
2061                    // https://woocommerce.github.io/woocommerce-rest-api-docs/v3.html?php#parameters
2062                    $orders = $woocommerce->get(
2063                        'orders',
2064                        array(
2065                            'page'     => 1,
2066                            'per_page' => 1,
2067                        )
2068                    );
2069
2070                    // retrieve page count from headers:
2071                    $last_response    = $woocommerce->http->getResponse();
2072                    $response_headers = $last_response->getHeaders();
2073
2074                    $lc_response_headers = array_change_key_case( $response_headers, CASE_LOWER );
2075                    if ( ! isset( $lc_response_headers['x-wp-totalpages'] ) ) {
2076                        return false;
2077                    }
2078
2079                    $this->woo_total_orders = (int) $lc_response_headers['x-wp-total'];
2080
2081                    // we can't rely on the X-WP-TotalPages header here, as we're only retrieving one order for speed
2082                    $this->woo_total_pages = ceil( $this->woo_total_orders / $this->orders_per_page );
2083
2084                } catch ( HttpClientException $e ) {
2085
2086                    // failed to connect
2087                    return false;
2088
2089                } catch ( Missing_Settings_Exception $e ) {
2090
2091                    // missing settings means couldn't load lib.
2092                    return false;
2093
2094                }
2095            } else {
2096
2097                // Local store
2098
2099                // Where we're trying to run without WooCommerce, fail.
2100                if ( ! function_exists( 'wc_get_orders' ) ) {
2101
2102                    $this->debug( 'Unable to return percentage completed as it appears WooCommerce is not installed.' );
2103                    return false;
2104
2105                } else {
2106
2107                    // retrieve orders (just to get total page count (≖_≖ ))
2108                    $orders = wc_get_orders(
2109                        array(
2110                            'limit'    => 1, // no need to retrieve more than one order here
2111                            'paged'    => 1,
2112                            'paginate' => true,
2113                        )
2114                    );
2115
2116                    $this->woo_total_orders = $orders->total;
2117
2118                    // we can't rely on $orders->max_num_pages here, as we're only retrieving one order for speed
2119                    $this->woo_total_pages = ceil( $this->woo_total_orders / $this->orders_per_page );
2120
2121                }
2122            }
2123        }
2124
2125        // calculate completeness
2126        if ( $this->woo_total_pages === 0 ) {
2127
2128            // no orders to sync, so complete
2129            $percentage_completed = 100;
2130
2131        } else {
2132
2133            $percentage_completed = $this->current_page / $this->woo_total_pages * 100;
2134
2135        }
2136
2137        $this->debug( 'Percentage completed: ' . $percentage_completed . '%' );
2138
2139        $this->debug( 'Pages completed: ' . $this->current_page . ' / ' . $this->woo_total_pages );
2140        $this->debug( 'Orders completed: ' . min( $this->current_page * $this->orders_per_page, $this->woo_total_orders ) . ' / ' . $this->woo_total_orders );
2141        $this->debug( 'Percentage completed: ' . $percentage_completed . '%' );
2142
2143        if ( $return_counts ) {
2144
2145            return array(
2146
2147                'page_no'              => $this->current_page,
2148                'total_pages'          => $this->woo_total_pages,
2149                'percentage_completed' => $percentage_completed,
2150
2151            );
2152
2153        }
2154
2155        // return
2156        if ( $percentage_completed >= 0 ) {
2157
2158            return $percentage_completed;
2159
2160        }
2161
2162        return false;
2163    }
2164
2165    /**
2166     * Filter contact data passed through the woo checkout
2167     * .. allows us to hook in support for things like WooCommerce Checkout Field Editor
2168     *
2169     * @param array $field_key
2170     * @param array $field_value
2171     * @param array $contact_data
2172     * @param array $order - WooCommerce order object passed down
2173     * @param array $custom_fields - CRM Contact custom fields details
2174     *
2175     * @return array ($contact_data potentially modified)
2176     */
2177    private function filter_checkout_contact_fields( $field_key, $field_value, $contact_data, $order, $custom_fields ) {
2178
2179        // Checkout Field Editor custom fields support, (where installed)
2180        // https://woocommerce.com/products/woocommerce-checkout-field-editor/
2181        if ( function_exists( 'wc_get_custom_checkout_fields' ) ) {
2182
2183            $contact_data = $this->checkout_field_editor_filter_field( $field_key, $field_value, $contact_data, $order, $custom_fields );
2184
2185        }
2186
2187        // Checkout Field Editor Pro custom fields support, (where installed)
2188        // https://wordpress.org/plugins/woo-checkout-field-editor-pro/
2189        if ( class_exists( 'THWCFD' ) ) {
2190
2191            $contact_data = $this->checkout_field_editor_pro_filter_field( $field_key, $field_value, $contact_data, $order, $custom_fields );
2192
2193        }
2194
2195        return $contact_data;
2196    }
2197
2198    /**
2199     * Filter to add Checkout Field Editor custom fields support, where installed
2200     * https://woocommerce.com/products/woocommerce-checkout-field-editor/
2201     *
2202     * @param array $field_key
2203     * @param array $field_value
2204     * @param array $contact_data
2205     * @param array $order - WooCommerce order object passed down
2206     * @param array $custom_fields - CRM Contact custom fields details
2207     *
2208     * @return array ($contact_data potentially modified)
2209     */
2210    private function checkout_field_editor_filter_field( $field_key, $field_value, $contact_data, $order, $custom_fields ) {
2211
2212        // Checkout Field Editor custom fields support, (where installed)
2213        if ( function_exists( 'wc_get_custom_checkout_fields' ) ) {
2214
2215            // get full fields
2216            $fields_info = wc_get_custom_checkout_fields( $order );
2217
2218            // catch specific cases
2219            if ( isset( $fields_info[ $field_key ] ) ) {
2220
2221                // format info from Checkout Field Editor
2222                $field_info = $fields_info[ $field_key ];
2223
2224                switch ( $field_info['type'] ) {
2225
2226                    // multiselect
2227                    case 'multiselect':
2228                        // here the value will be a csv with extra padding (spaces we don't store)
2229                        $contact_data[ $field_key ] = str_replace( ', ', ',', $field_value );
2230
2231                        break;
2232
2233                    // checkbox, singular
2234                    case 'checkbox':
2235                        // here the value will be 1 if it's checked,
2236                        // but in CRM we only have 'checkboxes' plural, so here we convert '1' to a checked matching box
2237                        // Here if checked, we'll check the first available checkbox
2238                        if ( $field_value == 1 ) {
2239
2240                            // get value
2241                            if ( isset( $custom_fields[ $field_key ] ) ) {
2242
2243                                $fields_csv = $custom_fields[ $field_key ][2];
2244                                if ( strpos( $fields_csv, ',' ) ) {
2245                                    $field_value = substr( $fields_csv, 0, strpos( $fields_csv, ',' ) );
2246                                } else {
2247                                    $field_value = $fields_csv;
2248                                }
2249                            }
2250
2251                            $contact_data[ $field_key ] = $field_value;
2252
2253                        }
2254
2255                        break;
2256
2257                }
2258            }
2259        }
2260
2261        return $contact_data;
2262    }
2263
2264    /**
2265     * Filter to add Checkout Field Editor Pro (Checkout Manager) for WooCommerce support, where installed
2266     * https://wordpress.org/plugins/woo-checkout-field-editor-pro/
2267     *
2268     * @param array $field_key
2269     * @param array $field_value
2270     * @param array $contact_data
2271     * @param array $order - WooCommerce order object passed down
2272     * @param array $custom_fields - CRM Contact custom fields details
2273     *
2274     * @return array ($contact_data potentially modified)
2275     */
2276    private function checkout_field_editor_pro_filter_field( $field_key, $field_value, $contact_data, $order, $custom_fields ) {
2277
2278        // Checkout Field Editor custom fields support, (where installed)
2279        if ( class_exists( 'THWCFD' ) ) {
2280
2281            // see if we have a matching custom field to infer type conversions from:
2282            if ( isset( $custom_fields[ $field_key ] ) ) {
2283
2284                // switch on type
2285                switch ( $custom_fields[ $field_key ][0] ) {
2286
2287                    // checkbox, singular
2288                    case 'checkbox':
2289                        // here the value will be 1 if it's checked,
2290                        // but in CRM we only have 'checkboxes' plural, so here we convert '1' to a checked matching box
2291                        // Here if checked, we'll check the first available checkbox
2292                        if ( $field_value == 1 ) {
2293
2294                            // get value
2295                            if ( isset( $custom_fields[ $field_key ] ) ) {
2296
2297                                $fields_csv = $custom_fields[ $field_key ][2];
2298                                if ( strpos( $fields_csv, ',' ) ) {
2299                                    $field_value = substr( $fields_csv, 0, strpos( $fields_csv, ',' ) );
2300                                } else {
2301                                    $field_value = $fields_csv;
2302                                }
2303                            }
2304
2305                            $contact_data[ $field_key ] = $field_value;
2306
2307                        }
2308
2309                        break;
2310
2311                }
2312            }
2313        }
2314
2315        return $contact_data;
2316    }
2317
2318    /**
2319     * Filter to add WooCommerce Checkout Add-ons fields support, where installed
2320     * https://woocommerce.com/products/woocommerce-checkout-add-ons/
2321     *
2322     * @param array $order_post_id - WooCommerce order id
2323     * @param array $contact_data
2324     * @param array $custom_fields - CRM Contact custom fields details
2325     *
2326     * @return array ($contact_data potentially modified)
2327     */
2328    private function checkout_add_ons_add_field_values( $order_post_id, $contact_data, $custom_fields ) {
2329
2330        global $zbs;
2331
2332        // WooCommerce Checkout Add-ons fields support, where installed
2333        if ( function_exists( 'wc_checkout_add_ons' ) ) {
2334
2335            $checkout_addons_instance = wc_checkout_add_ons();
2336            $field_values             = $checkout_addons_instance->get_order_add_ons( $order_post_id );
2337
2338            // Add any fields we have saved in Checkout Add-ons,
2339            // note this overrides any existing values, if conflicting
2340            if ( is_array( $field_values ) ) {
2341
2342                /*
2343                Example
2344                    Array(
2345                        [de22a81] => Array
2346                        (
2347                            [name] => tax-id-2
2348                            [checkout_label] => tax-id-2
2349                            [value] => 999
2350                            [normalized_value] => 999
2351                            [total] => 0
2352                            [total_tax] => 0
2353                            [fee_id] => 103
2354                        )
2355                    )
2356                */
2357
2358                foreach ( $field_values as $checkout_addon_key => $checkout_addon_field ) {
2359
2360                    $field_key = $zbs->DAL->makeSlug( $checkout_addon_field['name'] );
2361
2362                    // brutal addition/override of any fields passed
2363                    $contact_data[ $field_key ] = $checkout_addon_field['value'];
2364
2365                    // all array-type values (multi-select etc.) can be imploded for our storage:
2366                    // multiselect, multicheckbox
2367                    if ( is_array( $contact_data[ $field_key ] ) ) {
2368
2369                        // note we used `normalized_value` not `value`, because that matches our custom field storage
2370                        // ... e.g. "Blue" = `normalized_value`, "blue" = value (but we store case)
2371                        $contact_data[ $field_key ] = implode( ',', $checkout_addon_field['normalized_value'] );
2372
2373                    }
2374
2375                    // see if we have a matching custom field to infer type conversions from:
2376                    if ( isset( $custom_fields[ $field_key ] ) ) {
2377
2378                        // switch on type
2379                        switch ( $custom_fields[ $field_key ][0] ) {
2380
2381                            // Select, radio
2382                            case 'select':
2383                            case 'radio':
2384                                // note we used `normalized_value` not `value`, because that matches our custom field storage
2385                                // ... e.g. "Blue" = `normalized_value`, "blue" = value (but we store case)
2386                                $contact_data[ $field_key ] = $checkout_addon_field['normalized_value'];
2387
2388                                break;
2389
2390                            // checkbox, singular
2391                            case 'checkbox':
2392                                // here the value will be 1 if it's checked,
2393                                // but in CRM we only have 'checkboxes' plural, so here we convert '1' to a checked matching box
2394                                // Here if checked, we'll check the first available checkbox
2395                                if ( $contact_data[ $field_key ] == 1 ) {
2396
2397                                    // get value
2398                                    if ( isset( $custom_fields[ $field_key ] ) ) {
2399
2400                                        $fields_csv = $custom_fields[ $field_key ][2];
2401                                        if ( strpos( $fields_csv, ',' ) ) {
2402                                            $contact_data[ $field_key ] = substr( $fields_csv, 0, strpos( $fields_csv, ',' ) );
2403                                        } else {
2404                                            $contact_data[ $field_key ] = $fields_csv;
2405                                        }
2406                                    }
2407                                }
2408
2409                                break;
2410
2411                        }
2412                    }
2413                }
2414            }
2415        }
2416
2417        return $contact_data;
2418    }
2419
2420    /*
2421     * Catch site sync connection errors (and log count per site)
2422     */
2423    private function log_connection_error() {
2424
2425        // increment connection error count
2426        $this->woosync()->increment_sync_site_count( $this->site_key, 'site_connection_errors' );
2427
2428        // how many?
2429        $error_count = $this->woosync()->get_sync_site_attribute( $this->site_key, 'site_connection_errors', 0 );
2430
2431        $this->debug( 'External store connection error detected (' . $error_count . ')' );
2432
2433        if ( $error_count >= 3 ) {
2434
2435            $this->pause_site_due_to_connection_error();
2436
2437        }
2438    }
2439
2440    /*
2441     * Fired when a remote site errors out 3 times, pauses site and adds notification to admin area
2442     */
2443    private function pause_site_due_to_connection_error() {
2444
2445        $this->debug( 'External store connection error count exceeds maximum ... Pausing site connection' );
2446
2447        // pause
2448        $this->woosync()->pause_sync_site( $this->site_key );
2449
2450        // set notification
2451
2452        // check not fired within past day
2453        $existing_transient = get_transient( 'woosync.syncsite.paused.errors' );
2454        if ( ! $existing_transient ) {
2455            global $zbs;
2456
2457            // add notice & transient
2458            $reference = strtotime( 'today midnight' );
2459
2460            $connections_page_url = jpcrm_esc_link( $zbs->slugs['settings'] ) . '&tab=' . $zbs->modules->woosync->slugs['settings'] . '&subtab=' . $zbs->modules->woosync->slugs['settings_connections'];
2461            zeroBSCRM_notifyme_insert_notification( get_current_user_id(), -999, -1, 'woosync.syncsite.paused', $connections_page_url, $reference );
2462            set_transient( 'woosync.syncsite.paused.errors', 'woosync.syncsite.paused.errors', HOUR_IN_SECONDS * 24 );
2463
2464        }
2465    }
2466}