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