PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / 2.3.14
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). v2.3.14
3.0.3 3.0.2 3.0.1 trunk 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.3.0 2.3.1 2.3.10 2.3.11 2.3.12 2.3.13 2.3.14 2.3.2 2.3.3 2.3.4 2.3.5 2.3.6 2.3.7 2.3.8 2.3.9 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1 2.5.2 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.6.5 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.7.91 2.7.92 2.7.93 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0
commercebird / admin / includes / Actions / Sync / ExactOnlineSync.php
commercebird / admin / includes / Actions / Sync Last commit date
ExactOnlineSync.php 11 months ago ZohoCRMSync.php 1 year ago index.php 1 year ago
ExactOnlineSync.php
602 lines
1 <?php
2
3 namespace CommerceBird\Admin\Actions\Sync;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 use CommerceBird\Admin\Actions\Ajax\ExactOnlineAjax;
10 use CommerceBird\Admin\Connectors\CommerceBird;
11
12 class ExactOnlineSync {
13
14
15 /**
16 * Sync data from Exact Online.
17 *
18 * @param string $type product|customer
19 * @param array $data to sync from Exact Online
20 * @param bool $import import or update
21 * @return mixed
22 */
23 public static function sync( string $type, array $data, bool $import = false ) {
24 if ( empty( $type ) ) {
25 return false;
26 }
27 if ( $import ) {
28 self::import( $type, $data );
29 } else {
30 self::update( $type, $data );
31 }
32 }
33 /**
34 * Import data from Exact Online.
35 *
36 * for product data will be like,
37 * {
38 * "Code":string,
39 * "Description": string,
40 * "ID": string,
41 * "IsSalesItem": bool,
42 * "PictureName": null|string,
43 * "PictureUrl": string,
44 * "StandardSalesPrice": float,
45 * "Stock": int
46 * },
47 *
48 * for Order data will be like,
49 * {
50 * "MainContact": null|string,
51 * "Email": null|string,
52 * "ID": string,
53 * "Name": string,
54 * "AddressLine1": null|string,
55 * "AddressLine2": null|string,
56 * "City": string,
57 * "Country": string,
58 * "Phone": null|string,
59 * "Postcode": string
60 * }
61 * @param string $type of provided data;
62 * @param array $data of import
63 * @return mixed
64 */
65 public static function import( string $type, array $data ) {
66 // add logging
67 // $fd = fopen( __DIR__ . '/import.txt', 'a+' );
68 $endpoint = '';
69 $payload = array();
70
71 switch ( $type ) {
72 case 'product':
73 $endpoint = '/wc/v3/products/batch';
74 $filtered_data = array_filter(
75 $data,
76 function ( $item ) {
77 // Exclude item if product already exists via SKU
78 return ! wc_get_product_id_by_sku( $item['Code'] );
79 }
80 );
81 $payload = array(
82 'create' => array_map(
83 function ( $item ) {
84 // Skip image import if PictureName is 'placeholder_item'
85 $images = array();
86 if ( isset( $item['PictureName'] ) && 'placeholder_item' !== $item['PictureName'] ) {
87 $image_id = self::get_existing_image_id( $item['PictureName'] );
88 // If image exists, add it to the images array, else upload the image and add it to the images array
89 if ( $image_id ) {
90 $images[] = array( 'id' => $image_id );
91 } else {
92 $image_id = self::upload_image( $item['PictureUrl'], $item['PictureName'] );
93 // If image upload is successful, add it to the images array
94 if ( $image_id ) {
95 $images[] = array( 'id' => $image_id );
96 }
97 }
98 }
99 // Check if category exists with $item['ItemGroupDescription'], else create it and get the ID
100 if ( isset( $item['ItemGroupDescription'] ) ) {
101 $term = get_term_by( 'name', $item['ItemGroupDescription'], 'product_cat' );
102 $term_id = $term->term_id;
103 if ( empty( $term_id ) ) {
104 $term = wp_insert_term(
105 $item['ItemGroupDescription'],
106 'product_cat',
107 array(
108 'parent' => 0,
109 )
110 );
111 $term_id = $term['term_id'];
112 }
113 } else {
114 $term_id = 0;
115 }
116 // if stock exists then set manage_stock to true
117 $manage_stock = isset( $item['Stock'] ) ? true : false;
118 return array(
119 'name' => $item['Description'],
120 'sku' => $item['Code'],
121 'status' => 'publish',
122 'type' => 'simple',
123 'regular_price' => (string) $item['StandardSalesPrice'],
124 'images' => $images,
125 'stock_quantity' => $item['Stock'],
126 'manage_stock' => $manage_stock,
127 'categories' => array(
128 array(
129 'id' => $term_id,
130 ),
131 ),
132 'meta_data' => array(
133 array(
134 'key' => 'eo_item_id',
135 'value' => $item['ID'],
136 ),
137 array(
138 'key' => '_cost_price',
139 'value' => $item['CostPriceStandard'],
140 ),
141 array(
142 'key' => 'eo_unit',
143 'value' => $item['Unit'],
144 ),
145 ),
146 );
147 },
148 $filtered_data
149 ),
150 );
151 break;
152
153 case 'customer':
154 $endpoint = '/wc/v3/customers/batch';
155 $filtered_data = array_filter(
156 $data,
157 function ( $item ) {
158 // Exclude item if customer already exists via email
159 return ! get_user_by( 'email', $item['Email'] );
160 }
161 );
162 $payload = array(
163 'create' => array_map(
164 function ( $item ) {
165 if ( empty( $item['Email'] ) ) {
166 return null;
167 }
168 if ( ! empty( $item['VATNumber'] ) ) {
169 $first_name = '';
170 $last_name = '';
171 $company = $item['Name'];
172 } else {
173 $names = explode( ' ', $item['Name'] );
174 $first_name = array_shift( $names ); // Take the first word as first name
175 $last_name = implode( ' ', $names ); // Join the rest as last name
176 }
177 // generate username based on email
178 $username = explode( '@', $item['Email'] )[0];
179 // add random number to username if it already exists
180 $existing_user = get_user_by( 'login', $username );
181 if ( $existing_user ) {
182 $username .= '_' . wp_rand( 1000, 9999 );
183 }
184 $address = array(
185 'first_name' => $first_name,
186 'last_name' => $last_name,
187 'company' => $company ?? '',
188 'address_1' => $item['AddressLine1'] ?? '',
189 'address_2' => $item['AddressLine2'] ?? '',
190 'city' => $item['City'] ?? '',
191 'country' => $item['Country'] ?? '',
192 'postcode' => $item['Postcode'] ?? '',
193 'phone' => $item['Phone'] ?? '',
194 'email' => $item['Email'],
195 );
196 return array(
197 'email' => $item['Email'],
198 'first_name' => $first_name,
199 'last_name' => $last_name,
200 'username' => $username,
201 'billing' => $address,
202 'shipping' => $address,
203 'meta_data' => array(
204 array(
205 'key' => 'eo_account_id',
206 'value' => $item['ID'],
207 ),
208 array(
209 'key' => 'eo_contact_id',
210 'value' => $item['MainContact'] ?? '',
211 ),
212 ),
213 );
214 },
215 array_filter( $filtered_data, fn( $item ) => ! empty( $item['Email'] ) )
216 ),
217 );
218 break;
219
220 default:
221 return false;
222 }
223
224 if ( empty( $endpoint ) || empty( $payload['create'] ) ) {
225 return false;
226 }
227 // log the payload
228 // fwrite( $fd, print_r( $payload, true ) );
229 $request = new \WP_REST_Request( 'POST', $endpoint );
230 $request->set_body_params( $payload );
231 $response = rest_do_request( $request );
232 // error_log the message if response is error
233 if ( is_wp_error( $response ) ) {
234 // fwrite( $fd, $response->get_error_message() );
235 // throw the error if you want to handle it in the frontend
236 throw new \Exception( esc_html( $response->get_error_message() ) );
237 }
238 // fwrite( $fd, print_r( $response, true ) );
239 // fclose( stream: $fd );
240 return $response;
241 }
242
243 /**
244 * Update data based on Exact Online.
245 * @param string $type of provided data
246 * @return mixed
247 */
248 public static function update( string $type, array $data ) {
249 // $fd = fopen( __DIR__ . '/update.txt', 'w+' );
250 $endpoint = '';
251 $payload = array();
252 // log data
253 // fwrite( $fd, print_r( $data, true ) );
254
255 switch ( $type ) {
256 case 'product':
257 $filtered_data = array_filter(
258 $data,
259 function ( $item ) {
260 // Exclude item if product does not exists via SKU
261 return wc_get_product_id_by_sku( $item['Code'] );
262 }
263 );
264 $endpoint = '/wc/v3/products/batch';
265 $payload = array(
266 'update' => array_map(
267 function ( $item ) {
268 // Check if product exists via SKU, else get the product ID by title
269 $product_id = wc_get_product_id_by_sku( $item['Code'] ) ?: self::get_product_id_by_title( $item['Description'] );
270 // Featured image
271 if ( isset( $item['PictureName'] ) && 'placeholder_item' !== $item['PictureName'] ) {
272 $image_id = self::get_existing_image_id( $item['PictureName'] );
273 // If image exists, add it to the images array, else upload the image and add it to the images array
274 if ( $image_id ) {
275 set_post_thumbnail( $product_id, $image_id );
276 update_post_meta( $image_id, '_wp_attachment_image_alt', $item['Description'] );
277 update_post_meta( $product_id, '_thumbnail_id', $image_id );
278 wp_update_image_subsizes( $image_id );
279 } else {
280 $image_id = self::upload_image( $item['PictureUrl'], $item['PictureName'] );
281 // If image upload is successful, add it to the images array
282 if ( $image_id ) {
283 set_post_thumbnail( $product_id, $image_id );
284 update_post_meta( $image_id, '_wp_attachment_image_alt', $item['Description'] );
285 update_post_meta( $product_id, '_thumbnail_id', $image_id );
286 wp_update_image_subsizes( $image_id );
287 }
288 }
289 }
290 // update product category
291 if ( isset( $item['ItemGroupDescription'] ) ) {
292 // Check if term exists by name
293 $term = get_term_by( 'name', $item['ItemGroupDescription'], 'product_cat' );
294 $term_id = $term->term_id;
295 if ( empty( $term_id ) ) {
296 $term = wp_insert_term(
297 $item['ItemGroupDescription'],
298 'product_cat',
299 array(
300 'parent' => 0,
301 )
302 );
303 $term_id = $term['term_id'];
304 }
305 // update product category directly
306 wp_set_object_terms( $product_id, $term_id, 'product_cat' );
307 }
308 // update product name and slug if its different from current one
309 $product = wc_get_product( $product_id );
310 if ( $product->get_name() !== $item['Description'] ) {
311 $product->set_name( $item['Description'] );
312 $product->set_slug( sanitize_title( $item['Description'] ) );
313 $product->save();
314 }
315 // create meta_data array if product does not contain eo_item_id meta
316 $meta_data = get_post_meta( $product_id, 'eo_item_id', true );
317 if ( empty( $meta_data ) ) {
318 update_post_meta( $product_id, 'eo_item_id', $item['ID'] );
319 update_post_meta( $product_id, '_cost_price', $item['CostPriceStandard'] );
320 update_post_meta( $product_id, 'eo_unit', $item['Unit'] );
321 }
322 return array(
323 'id' => $product_id,
324 'regular_price' => (string) $item['StandardSalesPrice'],
325 );
326 },
327 $filtered_data
328 ),
329 );
330 break;
331
332 case 'customer':
333 $endpoint = '/wc/v3/customers/batch';
334 $payload = array(
335 'update' => array_map(
336 function ( $item ) {
337 $user = get_user_by( 'email', $item['Email'] );
338 return $user ? array(
339 'id' => $user->ID,
340 'meta_data' => array(
341 array(
342 'key' => 'eo_account_id',
343 'value' => $item['ID'],
344 ),
345 array(
346 'key' => 'eo_contact_id',
347 'value' => $item['MainContact'] ?? '',
348 ),
349 ),
350 // if $item['VATNumber'] is not empty then update the billing company name
351 'billing' => ! empty( $item['VATNumber'] ) ? array(
352 'company' => $item['Name'],
353 ) : null,
354 ) : null;
355 },
356 array_filter( $data, fn( $item ) => get_user_by( 'email', $item['Email'] ) )
357 ),
358 );
359 break;
360
361 case 'orders':
362 $endpoint = '/wc/v3/orders/batch';
363 $payload = array(
364 'update' => array_map(
365 function ( $item ) {
366 return array(
367 'id' => $item['Description'],
368 'meta_data' => array(
369 array(
370 'key' => 'eo_order_id',
371 'value' => $item['OrderID'],
372 ),
373 array(
374 'key' => 'eo_order_number',
375 'value' => $item['OrderNumber'],
376 ),
377 ),
378 );
379 },
380 $data
381 ),
382 );
383 break;
384
385 default:
386 return false;
387 }
388 // fwrite( $fd, print_r( $payload, true ) );
389 if ( empty( $endpoint ) || empty( $payload['update'] ) ) {
390 return false;
391 }
392 $request = new \WP_REST_Request( 'POST', $endpoint );
393 $request->set_body_params( $payload );
394 $response = rest_do_request( $request );
395 // error_log the message if response is error
396 if ( is_wp_error( $response ) ) {
397 error_log( 'Error ExactOnline Update: ' . $response->get_error_message() );
398 }
399 // fwrite( $fd, 'response: ' . print_r( $response, true ) );
400 // fclose( $fd );
401 return $response;
402 }
403
404 private static function get_product_id_by_title( string $product_title ) {
405 // Set up the query arguments
406 $args = array(
407 'post_type' => 'product',
408 'posts_per_page' => 1,
409 'fields' => 'ids',
410 's' => $product_title, // Search by product title
411 );
412
413 // Run the query
414 $query = new \WP_Query( $args );
415
416 // Get the product ID from the query results
417 $product_id = $query->post_count > 0 ? $query->posts[0] : 0;
418
419 // Reset post data
420 wp_reset_postdata();
421
422 return $product_id;
423 }
424
425 public static function get_payment_status_via_cron() {
426 // execute get_payment_status of ExactOnlineAjax class
427 $ajax = new ExactOnlineAjax();
428 $ajax->get_payment_status();
429 }
430
431 /**
432 * Process the payment status of the order via Exact Online.
433 * @param array $
434 * @return void
435 */
436 public static function cmbird_payment_status() {
437 $args = func_get_args();
438 $order_id = $args[0];
439 if ( empty( $order_id ) ) {
440 return;
441 }
442 $order = wc_get_order( $order_id );
443 $object = array();
444 $order_id = $order->get_id();
445 $object['OrderID'] = $order_id;
446 $customer_id = $order->get_customer_id();
447 // get the eo_account_id from the user meta
448 $object['AccountID'] = get_user_meta( $customer_id, 'eo_account_id', true );
449 $response = ( new CommerceBird() )->payment_status( $object );
450 // check response contains "Payment_Status" key
451 if ( ! isset( $response['Payment_Status'] ) ) {
452 return;
453 }
454 // if response is Paid then update the order status to completed
455 if ( 'Paid' === $response['Payment_Status'] ) {
456 // set order as paid
457 if ( $order->get_status() === 'completed' ) {
458 return;
459 }
460 $order->payment_complete();
461 $order->update_status( 'completed', __( 'Payment processed in Exact Online', 'commercebird' ) );
462 $order->save();
463 } elseif ( 'Unpaid' === $response['Payment_Status'] ) {
464 if ( $order->get_status() === 'on-hold' ) {
465 return;
466 }
467 $order->update_status( 'on-hold', __( 'Payment not processed in Exact Online', 'commercebird' ) );
468 $order->save();
469 }
470 }
471
472 /**
473 * Check if the image exists in the media library.
474 * @param string $picture_name
475 * @return int|false Attachment ID if exists, false otherwise
476 */
477 private static function get_existing_image_id( $picture_name ) {
478 global $wpdb;
479
480 // Strip extension from picture_name
481 $picture_title = pathinfo( $picture_name, PATHINFO_FILENAME );
482
483 // Prepare a LIKE match to catch sanitized versions
484 $like = '%' . $wpdb->esc_like( $picture_title ) . '%';
485
486 $attachment_id = $wpdb->get_var(
487 $wpdb->prepare(
488 "
489 SELECT ID FROM {$wpdb->posts}
490 WHERE post_type = 'attachment'
491 AND post_title LIKE %s
492 ORDER BY ID DESC
493 LIMIT 1
494 ",
495 $like
496 )
497 );
498
499 return $attachment_id ? (int) $attachment_id : false;
500 }
501
502 /**
503 * Upload the product image from Exact Online.
504 * @param string $product_id
505 * @param string $picture_name
506 * @return $attachment_id of the uploaded image if successful, otherwise false
507 */
508 private static function upload_image( $picture_url, $picture_name ) {
509 require_once ABSPATH . 'wp-admin/includes/media.php';
510 require_once ABSPATH . 'wp-admin/includes/file.php';
511 require_once ABSPATH . 'wp-admin/includes/image.php';
512
513 // Fetch image content
514 $response = wp_safe_remote_get( $picture_url, array( 'timeout' => 10 ) );
515
516 if ( is_wp_error( $response ) ) {
517 error_log( 'Error fetching image: ' . $response->get_error_message() );
518 return false;
519 }
520
521 $image_data = wp_remote_retrieve_body( $response );
522 if ( empty( $image_data ) ) {
523 return false;
524 }
525
526 // Generate filename
527 $upload_dir = wp_upload_dir();
528 $filename = sanitize_file_name( $picture_name );
529 $file_path = $upload_dir['path'] . '/' . $filename;
530
531 // Save the file
532 file_put_contents( $file_path, $image_data );
533
534 // Check if file was saved correctly
535 if ( ! file_exists( $file_path ) ) {
536 return false;
537 }
538
539 // Prepare file array for WordPress
540 $file = array(
541 'name' => $filename,
542 'type' => mime_content_type( $file_path ),
543 'tmp_name' => $file_path,
544 'size' => filesize( $file_path ),
545 );
546
547 // Upload to WordPress Media Library
548 $attachment_id = media_handle_sideload( $file, 0 );
549
550 // Check for errors
551 if ( is_wp_error( $attachment_id ) ) {
552 error_log( 'Error attaching image: ' . $attachment_id->get_error_message() );
553 return false;
554 }
555
556 return $attachment_id;
557 }
558
559 /**
560 * Sync orders via cron job.
561 * This function will get all orders from the last 30 days that have no eo_order_id or eo_invoice_id as meta key
562 * and send them to the send_orders function in CommerceBird class.
563 */
564 public static function sync_orders_via_cron() {
565 $logger = wc_get_logger();
566 $context = [ 'source' => 'cmbird_exact_online_orders' ];
567 // get all orders from the last 30 days that have no eo_order_id or eo_invoice_id as meta key
568 $start_date = gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) );
569 $end_date = time();
570 $orders = wc_get_orders(
571 array(
572 'status' => array_diff( array_keys( wc_get_order_statuses() ), array( 'wc-failed', 'wc-checkout-draft', 'wc-pending', 'wc-on-hold', 'wc-cancelled' ) ),
573 'limit' => -1,
574 'date_created' => $start_date . '...' . $end_date,
575 'return' => 'ids',
576 'meta_query' => array(
577 array(
578 'key' => 'eo_order_id',
579 'compare' => 'NOT EXISTS',
580 ),
581 ),
582 )
583 );
584 if ( empty( $orders ) ) {
585 return;
586 }
587 // send the orders to the send_orders function in CommerceBird class
588 $response = ( new CommerceBird() )->send_orders( array( 'orderIds' => $orders ) );
589 if ( is_string( $response ) || 200 !== $response['code'] ) {
590 // log the error
591 $logger->error(
592 'Error syncing Exact Online orders',
593 $context + array(
594 'response' => $response,
595 'orders' => $orders,
596 )
597 );
598 return;
599 }
600 }
601 }
602