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