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