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