PluginProbe
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / 2.8.0
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). v2.8.0
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 All 65 releases
commercebird / admin / includes / Actions / Ajax / ExactOnlineAjax.php
ExactOnlineAjax.php
1,330 lines 40.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace CommerceBird\Admin\Actions\Ajax;
4
5 use CommerceBird\Admin\Actions\Sync\ExactOnlineSync;
6 use CommerceBird\Admin\Actions\Ajax\SettingsAjax;
7 use CommerceBird\Admin\Connectors\Connector;
8 use CommerceBird\Admin\Traits\AjaxRequest;
9 use CommerceBird\Admin\Traits\LogWriter;
10 use CommerceBird\Admin\Traits\OptionStatus;
11 use CommerceBird\Admin\Traits\Singleton;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Handles Exact Online AJAX endpoints for the CommerceBird admin.
19 *
20 * @since 1.0.0
21 */
22 final class ExactOnlineAjax {
23 use Singleton;
24 use LogWriter;
25 use AjaxRequest;
26 use OptionStatus;
27
28 private const FORMS = array(
29 'product' => array(
30 'importProducts',
31 ),
32 'order' => array( 'range' ),
33 'customer' => array(
34 'importCustomers',
35 ),
36 'webhooks' => array(
37 'enable_StockPosition',
38 'enable_Item',
39 ),
40 'sync_settings' => array(
41 'allowedInvoicesStores',
42 'allowedOrdersStores',
43 'allowedMandateStores',
44 'errors_notifications_email',
45 ),
46 'general_settings' => array(
47 'divisionId',
48 'cost_center',
49 'cost_unit',
50 'email_layout',
51 'invoice_style',
52 'journal',
53 'products_gla',
54 'send_invoice',
55 'shipping_cost_label',
56 'shipping_gla',
57 'warehouse',
58 ),
59 'tax_mapping' => array( 'taxMapping' ),
60 'payment_status_mapping' => array(
61 'paymentStatusMapping',
62 ),
63 );
64 private const ACTIONS = array(
65 'save_woo_webhooks' => 'create_woo_webhooks',
66 'save_sync_order_via_cron' => 'sync_order',
67 'save_exact_online_connect' => 'connect_exact_online',
68 'get_exact_online_connect' => 'get_exact_connection',
69 'save_exact_online_cost_center' => 'cost_center_save',
70 'save_exact_online_cost_unit' => 'cost_unit_save',
71 'save_exact_online_gl_account' => 'gl_account_save',
72 'save_exact_online_payment_status' => 'get_payment_save',
73 'map_exact_online_product' => 'product_map',
74 'toggle_eo_stock' => 'toggle_stock',
75 'get_eo_stock_setting' => 'get_stock_setting',
76 'toggle_eo_webhook_publish' => 'toggle_webhook_publish',
77 'get_eo_webhook_publish_setting' => 'get_webhook_publish_setting',
78 'get_exact_orphaned_products' => 'get_orphaned_products',
79 'delete_exact_orphaned_products' => 'delete_orphaned_products',
80 'map_exact_online_customer' => 'customer_map',
81 'map_exact_online_order' => 'order_map',
82 'export_exact_online_order' => 'order_export',
83 'get_exact_online_webhooks' => 'webhooks_get',
84 'save_exact_online_webhooks' => 'webhooks_save',
85 'reset_exact_online_connect' => 'reset_mapping',
86 'get_exact_online_sync_settings' => 'get_exact_sync_settings',
87 'save_exact_online_sync_settings' => 'save_exact_sync_settings',
88 'get_exact_online_general_settings' => 'get_exact_general_settings',
89 'save_exact_online_general_settings' => 'save_exact_general_settings',
90 'get_exact_online_cost_center' => 'cost_center_get',
91 'get_exact_online_cost_unit' => 'cost_unit_get',
92 'get_exact_online_gl_accounts' => 'gl_account_get',
93 'get_exact_online_journals' => 'journals_get',
94 'get_exact_online_warehouses' => 'warehouses_get',
95 'get_exact_online_invoice_layouts' => 'invoice_layouts_get',
96 'get_exact_online_tax_mapping' => 'get_exact_tax_mapping',
97 'save_exact_online_tax_mapping' => 'save_exact_tax_mapping',
98 'get_exact_online_vat_codes' => 'get_exact_vat_codes',
99 'get_exact_online_payment_status_mapping' => 'get_exact_payment_status_mapping',
100 'save_exact_online_payment_status_mapping' => 'save_exact_payment_status_mapping',
101 'get_exact_online_payment_conditions' => 'get_exact_payment_conditions',
102 'get_wc_payment_methods' => 'wc_payment_gateways',
103 );
104 private const OPTIONS = array(
105 'connect' => array(
106 'exact_domain' => 'commercebird-exact-online-domain',
107 'woo_webhook_status' => 'commercebird-woo-webhook-status',
108 ),
109 'cost_center' => 'commercebird-exact-online-cost-center',
110 'cost_unit' => 'commercebird-exact-online-cost-unit',
111 'gl_accounts' => 'commercebird-exact-online-gl-accounts',
112 );
113
114 private const SOURCE = 'exact';
115
116 public function __construct() {
117 $this->load_actions();
118 add_action( 'cmbird_exact_online_sync_orders', array( $this, 'sync_via_cron' ) );
119 }
120 /**
121 * Load actions for the class.
122 *
123 * @return void
124 */
125 public function create_woo_webhooks() {
126 $this->verify( array( 'token', 'override' ) );
127 if ( ! isset( $this->data['token'] ) || empty( $this->data['token'] ) ) {
128 $this->errors['message'] = __( 'Token is required', 'commercebird' );
129 }
130
131 // Check if webhook status has value in option then don't create webhook and response with status.
132 $status = get_option( self::OPTIONS['connect']['woo_webhook_status'] );
133 if ( empty( $this->data['override'] ) && isset( $status ) && ! empty( $status ) ) {
134 $this->response['message'] = esc_html( $status );
135 } else {
136 $response = ( new Connector() )->create_woo_webbhook();
137 if ( empty( $response ) ) {
138 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
139 } elseif ( 200 === $response['code'] ) {
140 // Update the option with the webhook status.
141 update_option( self::OPTIONS['connect']['woo_webhook_status'], $response['message'] );
142 $this->response['message'] = __( 'Webhook created', 'commercebird' );
143 } else {
144 $this->errors['message'] = $response['message'];
145 }
146 }
147 $this->serve();
148 }
149
150
151 /**
152 * Sync orders from Exact Online.
153 *
154 * @return void
155 */
156 public function sync_order(): void {
157 $this->verify( array( 'sync' ) );
158 if ( $this->data['sync'] ) {
159 if ( ! wp_next_scheduled( 'cmbird_exact_online_sync_orders' ) ) {
160 wp_schedule_event( time(), 'hourly', 'cmbird_exact_online_sync_orders' );
161 }
162 } else {
163 wp_clear_scheduled_hook( 'cmbird_exact_online_sync_orders' );
164 }
165 update_option( 'cmbird_exact_online_sync_orders', (bool) $this->data['sync'] );
166 $this->response = array(
167 'success' => true,
168 'message' => __( 'Synced', 'commercebird' ),
169 );
170 $this->serve();
171 }
172
173 public function get_payment_save(): void {
174 $this->verify();
175 $this->get_payment_status();
176 }
177
178 /**
179 * Get Payment status from Exact Online. This is used to update the payment status of the order.
180 *
181 * @return void
182 */
183 public function get_payment_status(): void {
184 // $fd = fopen( __DIR__ . '/get_payment_status.log', 'a+' );
185
186 $start_date = gmdate( 'Y-m-d H:i:s', strtotime( '-200 days' ) );
187 $end_date = gmdate( 'Y-m-d H:i:s', strtotime( '-5 days' ) );
188
189 // Get all orders with the included statuses.
190 $orders = wc_get_orders(
191 array(
192 'status' => 'wc-on-hold',
193 'limit' => -1,
194 'date_created' => $start_date . '...' . $end_date,
195 'return' => 'ids',
196 )
197 );
198 if ( empty( $orders ) ) {
199 $this->response = array(
200 'success' => false,
201 'message' => __( 'No Invoice orders found', 'commercebird' ),
202 );
203 $this->serve();
204 }
205 foreach ( $orders as $order_id ) {
206 // send each order_id to wc action scheduler to process if not scheduled.
207 if ( ! as_has_scheduled_action( 'cmbird_payment_status', array( $order_id ) ) ) {
208 as_schedule_single_action(
209 time(),
210 'cmbird_payment_status',
211 array(
212 $order_id,
213 )
214 );
215 } else {
216 continue;
217 }
218 }
219 // fclose( $fd );
220
221 // Schedule the event to run weekly starting next week.
222 if ( ! wp_next_scheduled( 'cmbird_eo_get_payment_statuses' ) ) {
223 $seven_days_in_future = time() + 7 * DAY_IN_SECONDS;
224 wp_schedule_event( $seven_days_in_future, 'weekly', 'cmbird_eo_get_payment_statuses' );
225 }
226 $this->response = array(
227 'success' => true,
228 'message' => __( 'Payment status updated', 'commercebird' ),
229 );
230 $this->serve();
231 }
232
233 private function process_orders( $range ): bool {
234 $result = 0;
235 $orders = ( new Connector() )->order( $range );
236 if ( is_string( $orders ) ) {
237 $this->response = array(
238 'success' => false,
239 'message' => $orders,
240 );
241 $this->serve();
242 }
243 $chunked = array_chunk( $orders['orders'], 20 );
244 foreach ( $chunked as $chunked_order ) {
245 $result = as_schedule_single_action(
246 time(),
247 'cmbird_sync_eo',
248 array(
249 'orders',
250 wp_json_encode( $chunked_order ),
251 false,
252 )
253 );
254 if ( empty( $result ) ) {
255 break;
256 }
257 }
258 return $result > 0;
259 }
260
261 public function order_map() {
262 $this->verify( self::FORMS['order'] );
263 if ( empty( $this->data ) || empty( $this->data['range'] ) ) {
264 $this->response['success'] = false;
265 $this->response['message'] = __( 'Select dates', 'commercebird' );
266 $this->serve();
267 }
268 $result = $this->process_orders(
269 array(
270 'start_date' => gmdate(
271 'Y-m-d\TH:i:s.000\Z',
272 strtotime( $this->data['range'][0] )
273 ),
274 'end_date' => gmdate(
275 'Y-m-d\TH:i:s.000\Z',
276 strtotime( $this->data['range'][1] )
277 ),
278 ),
279 );
280 $this->response['success'] = $result > 0;
281 $this->response['data'] = $this->data['range'];
282 $this->response['message'] = __( 'Mapped', 'commercebird' );
283 $this->serve();
284 }
285
286 public function export_order( $start_date_raw, $end_date_raw ) {
287 // $fd = fopen( __DIR__ . '/export_order.log', 'a+' );
288
289 $start_date = gmdate( 'Y-m-d H:i:s', $start_date_raw );
290 $end_date = gmdate( 'Y-m-d H:i:s', $end_date_raw );
291 // Define the order statuses to exclude.
292 $exclude_statuses = array( 'wc-failed', 'wc-pending', 'wc-on-hold', 'wc-cancelled' );
293 $posts_per_page = 20;
294 $paged = 1;
295
296 do {
297 // Query to get orders.
298 $args = array(
299 'date_created' => $start_date . '...' . $end_date,
300 'status' => array_diff( array_keys( wc_get_order_statuses() ), $exclude_statuses ),
301 'limit' => $posts_per_page,
302 'paged' => $paged,
303 'orderby' => 'date',
304 'order' => 'ASC',
305 'return' => 'ids',
306 );
307 $orders = wc_get_orders( $args );
308
309 // Loop through orders and add customer note.
310 foreach ( $orders as $order_id ) {
311 $order = wc_get_order( $order_id );
312 $order->set_status( $order->get_status() );
313 $order->save();
314 }
315
316 // Increment the offset for the next batch.
317 ++$paged;
318 } while ( ! empty( $orders ) );
319 // fclose( $fd );
320 }
321
322 public function order_export() {
323 $this->verify( self::FORMS['order'] );
324 if ( empty( $this->data ) || empty( $this->data['range'] ) ) {
325 $this->response['success'] = false;
326 $this->response['message'] = __( 'Select dates', 'commercebird' );
327 $this->serve();
328 }
329 // Set the date range to last 30 days.
330 $start_date = strtotime( $this->data['range'][0] );
331 $end_date = strtotime( $this->data['range'][1] );
332 $this->export_order( $start_date, $end_date );
333 $this->response['success'] = true;
334 $this->response['message'] = __( 'Exported', 'commercebird' );
335 $this->serve();
336 }
337
338 public function product_map() {
339 $this->verify( self::FORMS['product'] );
340 $products = ( new Connector() )->products();
341 if ( is_string( $products ) ) {
342 $this->response = array(
343 'success' => false,
344 'message' => $products,
345 );
346 $this->serve();
347 }
348 $chunked = array_chunk( $products['items'], 100 );
349 foreach ( $chunked as $index => $chunked_products ) {
350 $base_key = wp_unique_id( 'cmbird_product_chunk_' );
351 $transient_key = $base_key . '_' . $index;
352 set_transient( $transient_key, $chunked_products, HOUR_IN_SECONDS );
353 // Wrap the arguments in an array.
354 as_schedule_single_action(
355 time(),
356 'cmbird_process_product_chunk',
357 array(
358 array(
359 'transient_key' => $transient_key,
360 'import_products' => (bool) $this->data['importProducts'],
361 ),
362 )
363 );
364 }
365 // handle more pages if response contains next_page_url.
366 if ( ! empty( $products['next_page_url'] ) ) {
367 $next_page = $products['next_page_url'];
368 $this->handle_more_pages( $next_page, 'product', 'customs/exact/bulk-items' );
369 }
370 $this->response['message'] = __( 'Items are being mapped in the background. You can visit other tabs :).', 'commercebird' );
371 $this->serve();
372 }
373
374 /**
375 * Toggle the enable_eo_stock option.
376 *
377 * @since 2.6.5
378 * @return void
379 */
380 public function toggle_stock(): void {
381 $this->verify();
382 $enable = isset( $this->data['enable_eo_stock'] ) ? (bool) $this->data['enable_eo_stock'] : false;
383 update_option( 'cmbird_enable_eo_stock', $enable );
384 $this->response = array(
385 'success' => true,
386 'message' => __( 'Stock setting saved!', 'commercebird' ),
387 );
388 $this->serve();
389 }
390
391 /**
392 * Get the enable_eo_stock setting.
393 *
394 * @since 2.6.5
395 * @return void
396 */
397 public function get_stock_setting(): void {
398 $this->verify();
399 $this->response = array(
400 'success' => true,
401 'enable' => (bool) get_option( 'cmbird_enable_eo_stock', false ),
402 );
403 $this->serve();
404 }
405
406 /**
407 * Toggle whether new products created via Item webhook are published automatically.
408 *
409 * @return void
410 */
411 public function toggle_webhook_publish(): void {
412 $this->verify();
413 $enable = isset( $this->data['enable_eo_webhook_publish'] ) ? (bool) $this->data['enable_eo_webhook_publish'] : false;
414 update_option( 'cmbird_enable_eo_webhook_publish', $enable );
415 $this->response = array(
416 'success' => true,
417 'message' => __( 'Publish setting saved!', 'commercebird' ),
418 );
419 $this->serve();
420 }
421
422 /**
423 * Get whether new products created via Item webhook are published automatically.
424 *
425 * @return void
426 */
427 public function get_webhook_publish_setting(): void {
428 $this->verify();
429 $this->response = array(
430 'success' => true,
431 'enable' => (bool) get_option( 'cmbird_enable_eo_webhook_publish', false ),
432 );
433 $this->serve();
434 }
435
436 /**
437 * Handles pagination of API responses for products, customers, and orders.
438 *
439 * @param string $next_page The URL of the next page to fetch.
440 * @param string $type The type of data being paginated ('product', 'customer', or 'order').
441 * @param string $endpoint The API endpoint to request.
442 * @param array $params Optional query parameters for the API request.
443 *
444 * @return void
445 */
446 private function handle_more_pages( $next_page, $type, $endpoint, $params = array() ) {
447 // Add the pagination param.
448 $params['__next'] = $next_page;
449
450 $response = ( new Connector() )->request( $endpoint, 'GET', array(), $params );
451
452 if ( is_string( $response ) || $response['code'] !== 200 ) {
453 $this->response = array(
454 'success' => false,
455 'message' => is_string( $response ) ? $response : $response['message'],
456 );
457 $this->serve();
458 }
459
460 $result = $response['data'];
461 $next_page = $result['next_page_url'] ?? null;
462
463 switch ( $type ) {
464 case 'product':
465 if ( empty( $result['items'] ) ) {
466 $this->response = array(
467 'success' => false,
468 'message' => __( 'No more products found', 'commercebird' ),
469 );
470 $this->serve();
471 }
472
473 $chunked = array_chunk( $result['items'], 100 );
474 foreach ( $chunked as $index => $chunked_products ) {
475 // sleep for 0.1 seconds to avoid hitting the API limit.
476 usleep( 100000 );
477 $base_key = wp_unique_id( 'cmbird_product_chunk_' );
478 $transient_key = $base_key . '_' . $index;
479 set_transient( $transient_key, $chunked_products, HOUR_IN_SECONDS );
480
481 as_schedule_single_action(
482 time() + 10,
483 'cmbird_process_product_chunk',
484 array(
485 array(
486 'transient_key' => $transient_key,
487 'import_products' => (bool) $this->data['importProducts'],
488 ),
489 )
490 );
491 }
492 break;
493
494 case 'customer':
495 if ( empty( $result['customers'] ) ) {
496 $this->response = array(
497 'success' => false,
498 'message' => __( 'No more customers found', 'commercebird' ),
499 );
500 $this->serve();
501 }
502
503 $chunked = array_chunk( $result['customers'], 100 );
504 foreach ( $chunked as $index => $chunked_customers ) {
505 // sleep for 0.1 seconds to avoid hitting the API limit.
506 usleep( 100000 );
507 $transient_key = 'cmbird_customer_chunk_' . time() . '_' . $index;
508 set_transient( $transient_key, $chunked_customers, HOUR_IN_SECONDS );
509
510 as_schedule_single_action(
511 time() + 10,
512 'cmbird_process_customer_chunk',
513 array(
514 array(
515 'transient_key' => $transient_key,
516 'import_customers' => (bool) $this->data['importCustomers'],
517 ),
518 )
519 );
520 }
521 break;
522
523 case 'order':
524 $sync = new ExactOnlineSync();
525 $sync->sync( 'order', $result['orders'], (bool) $this->data['importOrders'] );
526 break;
527 }
528
529 // 🔁 Recurse if there is a next page.
530 if ( $next_page ) {
531 $this->handle_more_pages( $next_page, $type, $endpoint, $params );
532 }
533 }
534
535 /**
536 * Maps customers from Exact Online to WooCommerce.
537 *
538 * @return void
539 */
540 public function customer_map() {
541 $this->verify( self::FORMS['customer'] );
542 $response = ( new Connector() )->customer();
543 if ( is_string( $response ) ) {
544 $this->response = array(
545 'success' => false,
546 'message' => $response,
547 );
548 $this->serve();
549 }
550 if ( empty( $response['customers'] ) ) {
551 $this->response = array(
552 'success' => false,
553 'message' => __( 'No customers found', 'commercebird' ),
554 );
555 $this->serve();
556 }
557 $chunked = array_chunk( $response['customers'], 100 );
558 foreach ( $chunked as $index => $chunked_customers ) {
559 $transient_key = 'cmbird_customer_chunk_' . time() . '_' . $index;
560 set_transient( $transient_key, $chunked_customers, HOUR_IN_SECONDS );
561 // Wrap the arguments in an array.
562 as_schedule_single_action(
563 time(),
564 'cmbird_process_customer_chunk',
565 array(
566 array(
567 'transient_key' => $transient_key,
568 'import_customers' => (bool) $this->data['importCustomers'],
569 ),
570 )
571 );
572 }
573 // handle more pages if response meta contains next.
574 if ( ! empty( $response['next_page_url'] ) ) {
575 $next_page = $response['next_page_url'];
576 $this->handle_more_pages( $next_page, 'customer', 'customs/exact/bulk-customers' );
577 }
578 $this->response['message'] = __( 'Items are being mapped in background. You can visit other tabs :).', 'commercebird' );
579 $this->serve();
580 }
581
582 /**
583 * Resets all product and customer mappings from Exact Online.
584 *
585 * @return void
586 */
587 public function reset_mapping() {
588 $this->verify();
589 // Check if resetAll is true (sent from Vue).
590 $reset_all = isset( $this->data['reset'] ) && 1 === $this->data['reset'];
591 if ( ! $reset_all ) {
592 delete_option( self::OPTIONS['connect']['token'] );
593 $response = ( new Connector() )->reset_exact_connection();
594 if ( is_string( $response ) || ( isset( $response['code'] ) && $response['code'] !== 200 ) ) {
595 $this->errors['message'] = is_string( $response ) ? $response : $response['message'];
596 } elseif ( isset( $response['code'] ) && 200 === $response['code'] ) {
597 $this->response['message'] = __( 'Connection reset completed', 'commercebird' );
598 } else {
599 $this->errors['message'] = __( 'Unable to reset connection', 'commercebird' );
600 }
601 $this->serve();
602 }
603 global $wpdb;
604 // Delete all transients related to products and customers.
605 $transients = array( 'cmbird_product_chunk_', 'cmbird_customer_chunk_' );
606 foreach ( $transients as $transient ) {
607 $wpdb->query(
608 $wpdb->prepare(
609 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
610 $transient . '%'
611 )
612 );
613 }
614 // Delete all product and customer meta keys.
615 $wpdb->query( "DELETE FROM {$wpdb->prefix}postmeta WHERE meta_key = 'eo_item_id'" );
616 $wpdb->query( "DELETE FROM {$wpdb->prefix}usermeta WHERE meta_key IN ('eo_account_id', 'eo_contact_id', 'eo_gl_account')" );
617 // return response.
618 $this->response['message'] = __( 'Mapping reset completed', 'commercebird' );
619 $this->serve();
620 }
621
622 public function cost_center_get_options() {
623 return get_option( self::OPTIONS['cost_center'], array() );
624 }
625
626 public function cost_unit_get_options() {
627 return get_option( self::OPTIONS['cost_unit'], array() );
628 }
629
630 public function gl_account_get_options() {
631 return get_option( self::OPTIONS['gl_accounts'], array() );
632 }
633
634
635 public function cost_center_save() {
636 $this->verify();
637 $response = ( new Connector() )->cost_centers();
638 if ( empty( $response ) ) {
639 $this->errors['message'] = __( 'Cost centers not found', 'commercebird' );
640 } else {
641 $centers = array_map(
642 function ( $item ) {
643 return "{$item['Code']}-{$item['Description']}";
644 },
645 $response['data']
646 );
647 update_option( self::OPTIONS['cost_center'], $centers );
648 // remove each meta if it does not exists in the cost centers.
649 global $wpdb;
650 if ( ! empty( $centers ) ) {
651 // Sanitize and prepare the units for the SQL query.
652 $placeholders = implode( ',', array_fill( 0, count( $centers ), '%s' ) );
653
654 // Prepare the query string.
655 $query = "DELETE FROM {$wpdb->prefix}wc_orders_meta
656 WHERE meta_key = 'costcenter'
657 AND meta_value NOT IN ($placeholders)";
658 $wpdb->query( $wpdb->prepare( $query, ...$centers ) ); // phpcs:ignore.
659 // also remove from postmeta table.
660 $query2 = "DELETE FROM {$wpdb->prefix}postmeta
661 WHERE meta_key = 'costcenter'
662 AND meta_value NOT IN ($placeholders)";
663 $wpdb->query( $wpdb->prepare( $query2, ...$centers ) ); // phpcs:ignore.
664 } else {
665 // If $units is empty, delete all meta keys with 'costunit'.
666 $wpdb->query(
667 "DELETE FROM {$wpdb->prefix}wc_orders_meta
668 WHERE meta_key = 'costcenter'"
669 );
670 }
671 $this->response['message'] = __( 'Cost centers saved', 'commercebird' );
672 }
673 $this->serve();
674 }
675
676 public function cost_unit_save() {
677 $this->verify();
678 $response = ( new Connector() )->cost_units();
679 if ( empty( $response ) ) {
680 $this->errors['message'] = __( 'Cost units not found', 'commercebird' );
681 } else {
682 $units = array_map(
683 function ( $item ) {
684 return "{$item['Code']}-{$item['Description']}";
685 },
686 $response['data']
687 );
688 update_option( self::OPTIONS['cost_unit'], $units );
689 // remove each meta if it does not exists in the units.
690 global $wpdb;
691 if ( ! empty( $units ) ) {
692 // Sanitize and prepare the units for the SQL query.
693 $placeholders = implode( ',', array_fill( 0, count( $units ), '%s' ) );
694
695 // Prepare the query string.
696 $query = "DELETE FROM {$wpdb->prefix}wc_orders_meta
697 WHERE meta_key = 'costunit'
698 AND meta_value NOT IN ($placeholders)";
699 $wpdb->query( $wpdb->prepare( $query, ...$units ) ); // phpcs:ignore.
700 // also remove from postmeta table.
701 $query2 = "DELETE FROM {$wpdb->prefix}postmeta
702 WHERE meta_key = 'costunit'
703 AND meta_value NOT IN ($placeholders)";
704 $wpdb->query( $wpdb->prepare( $query2, ...$units ) ); // phpcs:ignore.
705 } else {
706 // If $units is empty, delete all meta keys with 'costunit'.
707 $wpdb->query(
708 "DELETE FROM {$wpdb->prefix}wc_orders_meta
709 WHERE meta_key = 'costunit'"
710 );
711 }
712 $this->response['message'] = __( 'Cost units saved', 'commercebird' );
713 }
714 $this->serve();
715 }
716
717 public function gl_account_save() {
718 $this->verify();
719 $response = ( new Connector() )->gl_accounts();
720 if ( empty( $response ) ) {
721 $this->errors['message'] = __( 'GL accounts not found', 'commercebird' );
722 } else {
723 $accounts = array_map(
724 function ( $item ) {
725 return "{$item['ID']} : {$item['Description']}";
726 },
727 $response['data']
728 );
729 update_option( self::OPTIONS['gl_accounts'], $accounts );
730 $this->response['message'] = __( 'GL accounts saved', 'commercebird' );
731 }
732 $this->serve();
733 }
734
735 public function sync_via_cron() {
736 $start_date = strtotime( '-1 day' );
737 $end_date = strtotime( 'now' );
738
739 $continue = $this->process_orders(
740 array(
741 'start_date' => $start_date,
742 'end_date' => $end_date,
743 )
744 );
745
746 if ( $continue ) {
747 $this->export_order( $start_date, $end_date );
748 }
749 }
750
751 public function connect_exact_online() {
752 $this->verify( array( 'exact_domain' ) );
753 if ( ! isset( $this->data['exact_domain'] ) || empty( $this->data['exact_domain'] ) ) {
754 $this->errors['message'] = __( 'Please select exact domain', 'commercebird' );
755 } else {
756 update_option( self::OPTIONS['connect']['exact_domain'], $this->data['exact_domain'] );
757 $response = ( new Connector() )->connect_exact( $this->data['exact_domain'] );
758 if ( empty( $response ) ) {
759 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
760 } elseif ( 200 === $response['code'] ) {
761 $this->response['message'] = __( 'Account generared', 'commercebird' );
762 $this->response['auth_url'] = $response['data']['auth_url'];
763 $this->response['message'] = __( 'Saved', 'commercebird' );
764 } else {
765 $this->errors['message'] = $response['message'];
766 }
767 }
768
769 $this->serve();
770 }
771
772 public function get_exact_connection() {
773 $this->verify();
774 $exact_domain = get_option( self::OPTIONS['connect']['exact_domain'], '' );
775 $token = SettingsAjax::instance()->get_token();
776 if ( ! empty( $exact_domain ) && ! empty( $token ) ) {
777 $response = ( new Connector() )->get_exact_divisions();
778 if ( empty( $response ) ) {
779 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
780 } else {
781 $this->response['message'] = $response['message'];
782 if ( 200 === $response['code'] ) {
783 $this->response['connection'] = $response['data'];
784 }
785 }
786 $this->response['exact_domain'] = $exact_domain;
787 $this->response['token'] = $token;
788 }
789 $this->serve();
790 }
791
792
793 /**
794 * @description Retrieves the general account settings.
795 **/
796 public function get_exact_sync_settings() {
797 $this->verify();
798 $response = ( new Connector() )->get_account_settings();
799 if ( empty( $response ) ) {
800 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
801 } else {
802 $this->response['message'] = $response['message'];
803 if ( 200 === $response['code'] ) {
804 $this->response['settings'] = $response['data'];
805 }
806 }
807 $this->serve();
808 }
809
810 /**
811 * @description Function to save the general account settings.
812 */
813 public function save_exact_sync_settings() {
814 $this->verify( self::FORMS['sync_settings'] );
815
816 $settings = $this->data;
817
818 if ( empty( $settings ) ) {
819 $this->errors['message'] = __( 'Invalid sync settings payload.', 'commercebird' );
820 $this->serve();
821 }
822
823 $response = ( new Connector() )->save_account_settings( $settings );
824
825 if ( empty( $response ) ) {
826 $this->errors['message'] = __( 'Unable to save settings.', 'commercebird' );
827 } else {
828 $this->response['message'] = $response['message'];
829 if ( ! empty( $response['code'] ) && 200 === $response['code'] ) {
830 $this->response['success'] = true;
831 $this->response['settings'] = $response['data'] ?? array();
832 }
833 }
834
835 $this->serve();
836 }
837
838 /**
839 * @description Retrieves mapped general settings for exact.
840 **/
841 public function get_exact_general_settings() {
842 $this->verify();
843 $response = ( new Connector() )->exact_general_settings();
844 if ( empty( $response ) ) {
845 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
846 } else {
847 $this->response['message'] = $response['message'];
848 if ( $response['code'] === 200 ) {
849 $this->response['settings'] = $response['data'];
850 }
851 }
852 $this->serve();
853 }
854
855 /**
856 * @description Function to save the mapped general settings.
857 */
858 public function save_exact_general_settings() {
859 $this->verify( self::FORMS['general_settings'] );
860
861 $settings = $this->data;
862
863 if ( empty( $settings ) ) {
864 $this->errors['message'] = __( 'Invalid general settings payload.', 'commercebird' );
865 $this->serve();
866 }
867
868 $response = ( new Connector() )->save_exact_general_settings( $settings );
869
870 if ( empty( $response ) ) {
871 $this->errors['message'] = __( 'Unable to save settings.', 'commercebird' );
872 } else {
873 $this->response['message'] = $response['message'];
874 if ( ! empty( $response['code'] ) && $response['code'] === 200 ) {
875 $this->response['success'] = true;
876 $this->response['settings'] = $response['data'] ?? array();
877 }
878 }
879
880 $this->serve();
881 }
882
883 /**
884 * @description Retrieves Journals list from exact.
885 **/
886 public function journals_get() {
887 $this->verify();
888 $response = ( new Connector() )->journals();
889 if ( empty( $response ) ) {
890 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
891 } else {
892 $this->response['message'] = $response['message'];
893 if ( $response['code'] === 200 ) {
894 $this->response['settings'] = $response['data'];
895 }
896 }
897 $this->serve();
898 }
899
900 /**
901 * @description Retrieves warehoused from exact.
902 **/
903 public function warehouses_get() {
904 $this->verify();
905 $response = ( new Connector() )->exact_warehouses();
906 if ( empty( $response ) ) {
907 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
908 } else {
909 $this->response['message'] = $response['message'];
910 if ( $response['code'] === 200 ) {
911 $this->response['settings'] = $response['data'];
912 }
913 }
914 $this->serve();
915 }
916
917 /**
918 * @description Retrieves invoice layouts from exact.
919 **/
920 public function invoice_layouts_get() {
921 $this->verify();
922 $response = ( new Connector() )->invoice_layouts();
923 if ( empty( $response ) ) {
924 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
925 } else {
926 $this->response['message'] = $response['message'];
927 if ( $response['code'] === 200 ) {
928 $this->response['settings'] = $response['data'];
929 }
930 }
931 $this->serve();
932 }
933
934 /**
935 * @description Retrieves invoice layouts from exact.
936 **/
937 public function gl_account_get() {
938 $this->verify();
939 $response = ( new Connector() )->gl_accounts();
940 if ( empty( $response ) ) {
941 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
942 } else {
943 $this->response['message'] = $response['message'];
944 if ( $response['code'] === 200 ) {
945 $this->response['settings'] = $response['data'];
946 }
947 }
948 $this->serve();
949 }
950
951 /**
952 * @description Retrieves cost centers for exact.
953 **/
954 public function cost_center_get() {
955 $this->verify();
956 $response = ( new Connector() )->cost_centers();
957 if ( empty( $response ) ) {
958 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
959 } else {
960 $this->response['message'] = $response['message'];
961 if ( $response['code'] === 200 ) {
962 $this->response['settings'] = $response['data'];
963 }
964 }
965 $this->serve();
966 }
967
968 /**
969 * @description Retrieves cost units for exact.
970 **/
971 public function cost_unit_get() {
972 $this->verify();
973 $response = ( new Connector() )->cost_units();
974 if ( empty( $response ) ) {
975 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
976 } else {
977 $this->response['message'] = $response['message'];
978 if ( $response['code'] === 200 ) {
979 $this->response['settings'] = $response['data'];
980 }
981 }
982 $this->serve();
983 }
984
985 /**
986 * Sets the webhooks settings and updates the status.
987 *
988 * @return void
989 */
990 public function webhooks_save(): void {
991 $this->verify( self::FORMS['webhooks'] );
992 $form_data = $this->data;
993 foreach ( $form_data as $topic => $is_active ) {
994 $webhooks[] = array(
995 'topic' => $topic,
996 'status' => $is_active ? 'active' : 'inactive',
997 );
998 }
999 $response = ( new Connector() )->subscribe_exact_webhooks( $webhooks );
1000 // error_log( print_r( $response, true ) );
1001 $this->option_status_update( $this->data );
1002 $this->response = is_array( $response ) ? $response : array( 'message' => $response );
1003 $this->serve();
1004 }
1005
1006 /**
1007 * Retrieves the wehbhook settings.
1008 *
1009 * @return void
1010 */
1011 public function webhooks_get(): void {
1012 $this->verify();
1013 $this->response = $this->option_status_get( self::FORMS['webhooks'] );
1014 $this->serve();
1015 }
1016
1017 /**
1018 * @description Retrieves exact vat codes.
1019 **/
1020 public function get_exact_vat_codes() {
1021 $this->verify();
1022 $response = ( new Connector() )->get_exact_vat_codes();
1023 if ( empty( $response ) ) {
1024 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
1025 } else {
1026 $this->response['message'] = $response['message'];
1027 if ( $response['code'] === 200 ) {
1028 $this->response['settings'] = $response['data'];
1029 }
1030 }
1031 $this->serve();
1032 }
1033
1034 /**
1035 * @description Retrieves mapped tax settings.
1036 **/
1037 public function get_exact_tax_mapping() {
1038 $this->verify();
1039 $response = ( new Connector() )->get_exact_tax_mapping();
1040 if ( empty( $response ) ) {
1041 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
1042 } else {
1043 $this->response['message'] = $response['message'];
1044 if ( $response['code'] === 200 ) {
1045 $this->response['taxMapping'] = $response['data'];
1046 }
1047 }
1048 $this->serve();
1049 }
1050
1051 /**
1052 * @description Function to save the mapped taxes.
1053 */
1054 public function save_exact_tax_mapping() {
1055 $this->verify( self::FORMS['tax_mapping'] );
1056 $settings = $this->data['taxMapping'] ?? array();
1057 if ( empty( $settings ) ) {
1058 $this->errors['message'] = __( 'Invalid tax mapping payload.', 'commercebird' );
1059 $this->serve();
1060 }
1061
1062 $response = ( new Connector() )->save_exact_tax_mapping( $settings );
1063
1064 if ( empty( $response ) ) {
1065 $this->errors['message'] = __( 'Unable to save tax mapping.', 'commercebird' );
1066 } else {
1067 $this->response['message'] = $response['message'];
1068 if ( ! empty( $response['code'] ) && 200 === $response['code'] ) {
1069 $this->response['success'] = true;
1070 $this->response['settings'] = $response['data'] ?? array();
1071 }
1072 }
1073
1074 $this->serve();
1075 }
1076
1077 /**
1078 * @description Retrieves exact payment conditions.
1079 **/
1080 public function get_exact_payment_conditions() {
1081 $this->verify();
1082 $response = ( new Connector() )->get_exact_payment_conditions();
1083 if ( empty( $response ) ) {
1084 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
1085 } else {
1086 $this->response['message'] = $response['message'];
1087 if ( $response['code'] === 200 ) {
1088 $this->response['payment_conditions'] = $response['data'];
1089 }
1090 }
1091 $this->serve();
1092 }
1093 /**
1094 * Retrieves mapped payment status mapping.
1095 *
1096 * @return void
1097 */
1098 public function get_exact_payment_status_mapping() {
1099 $this->verify();
1100 $response = ( new Connector() )->get_exact_payment_status_mapping();
1101 if ( empty( $response ) ) {
1102 $this->errors['message'] = __( 'Unable to connect', 'commercebird' );
1103 } else {
1104 $this->response['message'] = $response['message'];
1105 if ( $response['code'] === 200 ) {
1106 $this->response['paymentStatusMapping'] = $response['data'];
1107 }
1108 }
1109 $this->serve();
1110 }
1111
1112 /**
1113 * @description Function to save the mapped taxes.
1114 */
1115 public function save_exact_payment_status_mapping() {
1116 $this->verify( self::FORMS['payment_status_mapping'] );
1117 $settings = $this->data['paymentStatusMapping'] ?? array();
1118 if ( empty( $settings ) ) {
1119 $this->errors['message'] = __( 'Invalid payment status mapping payload.', 'commercebird' );
1120 $this->serve();
1121 }
1122
1123 $response = ( new Connector() )->save_exact_payment_status_mapping( $settings );
1124
1125 if ( empty( $response ) ) {
1126 $this->errors['message'] = __( 'Unable to save payment status mapping.', 'commercebird' );
1127 } else {
1128 $this->response['message'] = $response['message'];
1129 if ( ! empty( $response['code'] ) && 200 === $response['code'] ) {
1130 $this->response['success'] = true;
1131 $this->response['paymentStatusMapping'] = $response['data'] ?? array();
1132 }
1133 }
1134
1135 $this->serve();
1136 }
1137
1138 /**
1139 * Retrieves available WooCommerce payment gateways.
1140 *
1141 * @return void
1142 */
1143 public function wc_payment_gateways() {
1144 $gateways = array();
1145 $available_gateways = WC()->payment_gateways->payment_gateways();
1146 // $available_gateways = $wc_gateways->get_available_payment_gateways();
1147 foreach ( $available_gateways as $gateway_id => $gateway ) {
1148 if ( 'yes' !== $gateway->enabled ) {
1149 continue; // skip disabled gateways.
1150 }
1151 $gateways[] = array(
1152 'id' => $gateway_id,
1153 'title' => $gateway->get_title(),
1154 'description' => $gateway->get_description(),
1155 'method_title' => $gateway->method_title,
1156 'enabled' => $gateway->enabled,
1157 'supports' => $gateway->supports,
1158 );
1159 }
1160 $this->response['success'] = true;
1161 $this->response['gateways'] = $gateways;
1162 $this->serve();
1163 }
1164
1165 /**
1166 * Get orphaned products (exist in WooCommerce but not in Exact Online).
1167 *
1168 * @return void
1169 */
1170 public function get_orphaned_products() {
1171 global $wpdb;
1172
1173 // Prevent stale results: if mapping chunks are still running the transient is incomplete.
1174 $pending_jobs = as_get_scheduled_actions(
1175 array(
1176 'hook' => 'cmbird_process_product_chunk',
1177 'status' => \ActionScheduler_Store::STATUS_PENDING,
1178 'per_page' => 1,
1179 ),
1180 'ids'
1181 );
1182 if ( ! empty( $pending_jobs ) ) {
1183 $this->response['success'] = false;
1184 $this->response['products'] = array();
1185 $this->response['message'] = __( 'Product mapping is still in progress. Please wait until it completes before checking orphaned products.', 'commercebird' );
1186 $this->serve();
1187 return;
1188 }
1189
1190 $synced_skus = get_option( 'cmbird_eo_synced_skus', false );
1191 if ( false === $synced_skus ) {
1192 $legacy_synced_skus = get_transient( 'cmbird_eo_synced_skus' );
1193 if ( ! empty( $legacy_synced_skus ) && is_array( $legacy_synced_skus ) ) {
1194 $synced_skus = array_values( array_unique( $legacy_synced_skus ) );
1195 update_option( 'cmbird_eo_synced_skus', $synced_skus, false );
1196 delete_transient( 'cmbird_eo_synced_skus' );
1197 }
1198 }
1199
1200 if ( false === $synced_skus || empty( $synced_skus ) ) {
1201 $this->response['success'] = true;
1202 $this->response['products'] = array();
1203 $this->response['message'] = __( 'No sync data available. Please run product mapping first.', 'commercebird' );
1204 $this->serve();
1205 return;
1206 }
1207
1208 // Create placeholders for the IN clause.
1209 $placeholders = implode( ',', array_fill( 0, count( $synced_skus ), '%s' ) );
1210
1211 // GROUP BY p.ID prevents duplicate rows when a product has multiple eo_item_id meta entries.
1212 $results = $wpdb->get_results(
1213 $wpdb->prepare(
1214 "SELECT p.ID, p.post_title, pm_sku.meta_value as sku
1215 FROM {$wpdb->posts} p
1216 INNER JOIN {$wpdb->postmeta} pm_eo ON p.ID = pm_eo.post_id AND pm_eo.meta_key = 'eo_item_id'
1217 LEFT JOIN {$wpdb->postmeta} pm_sku ON p.ID = pm_sku.post_id AND pm_sku.meta_key = '_sku'
1218 WHERE p.post_type IN ( 'product', 'product_variation' )
1219 AND p.post_status = 'publish'
1220 AND (pm_sku.meta_value NOT IN ({$placeholders}) OR pm_sku.meta_value IS NULL)
1221 GROUP BY p.ID",
1222 ...$synced_skus
1223 ),
1224 ARRAY_A
1225 );
1226 $orphaned_products = array();
1227 foreach ( $results as $row ) {
1228 $orphaned_products[] = array(
1229 'id' => (int) $row['ID'],
1230 'name' => $row['post_title'],
1231 'sku' => $row['sku'] ?? '',
1232 );
1233 }
1234
1235 $this->response['success'] = true;
1236 $this->response['products'] = $orphaned_products;
1237 $this->response['count'] = count( $orphaned_products );
1238 $this->response['message'] = sprintf(
1239 /* translators: %d: number of orphaned products */
1240 _n(
1241 'Found %d product that exists in WooCommerce but not in Exact Online.',
1242 'Found %d products that exist in WooCommerce but not in Exact Online.',
1243 count( $orphaned_products ),
1244 'commercebird'
1245 ),
1246 count( $orphaned_products )
1247 );
1248 $this->serve();
1249 }
1250
1251 /**
1252 * Delete orphaned products from WooCommerce.
1253 *
1254 * @return void
1255 */
1256 public function delete_orphaned_products() {
1257 $this->verify( array( 'productIds' ) );
1258
1259 if ( empty( $this->data['productIds'] ) || ! is_array( $this->data['productIds'] ) ) {
1260 $this->errors['message'] = __( 'No product IDs provided', 'commercebird' );
1261 $this->serve();
1262 return;
1263 }
1264
1265 $product_ids = array_values(
1266 array_filter(
1267 array_unique( array_map( 'intval', $this->data['productIds'] ) )
1268 )
1269 );
1270
1271 if ( empty( $product_ids ) ) {
1272 $this->errors['message'] = __( 'No valid product IDs provided', 'commercebird' );
1273 $this->serve();
1274 return;
1275 }
1276
1277 // Persist the deletion queue so the latest request is available in wp_options.
1278 update_option( 'cmbird_exact_orphaned_product_ids', $product_ids, false );
1279
1280 $deleted = array();
1281 $failed = array();
1282
1283 foreach ( $product_ids as $product_id ) {
1284 $result = wp_delete_post( $product_id, true );
1285 if ( false !== $result ) {
1286 wc_delete_product_transients( $product_id );
1287 $deleted[] = $product_id;
1288 } else {
1289 $failed[] = $product_id;
1290 }
1291 }
1292
1293 if ( ! empty( $failed ) ) {
1294 update_option( 'cmbird_exact_orphaned_product_ids', $failed, false );
1295 } else {
1296 delete_option( 'cmbird_exact_orphaned_product_ids' );
1297 }
1298
1299 // Clear synced SKUs cache after direct deletion.
1300 delete_option( 'cmbird_eo_synced_skus' );
1301
1302 $this->response['success'] = empty( $failed );
1303 $this->response['deleted'] = count( $deleted );
1304 $this->response['failed'] = count( $failed );
1305
1306 if ( empty( $failed ) ) {
1307 $this->response['message'] = sprintf(
1308 /* translators: %d: number of products */
1309 _n(
1310 'Deleted %d orphaned product.',
1311 'Deleted %d orphaned products.',
1312 count( $deleted ),
1313 'commercebird'
1314 ),
1315 count( $deleted )
1316 );
1317 } else {
1318 $this->response['message'] = sprintf(
1319 /* translators: 1: deleted count, 2: failed count */
1320 __( 'Deleted %1$d orphaned products. Failed to delete %2$d products.', 'commercebird' ),
1321 count( $deleted ),
1322 count( $failed )
1323 );
1324 $this->response['failed_ids'] = $failed;
1325 }
1326
1327 $this->serve();
1328 }
1329 }
1330