PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / 1.0.64
Timetics – Appointment Booking Calendar & Scheduling v1.0.64
1.0.64 1.0.62 1.0.63 1.0.61 1.0.60 1.0.59 1.0.58 1.0.57 1.0.56 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.19 1.0.2 1.0.20 1.0.21 All 65 releases
timetics / core / integrations / woocommerce / hooks.php

hooks.php in Timetics – Appointment Booking Calendar & Scheduling 1.0.64, at core/integrations/woocommerce/hooks.php

906 lines 29.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WooCommerce Hooks Class
4 *
5 * @package Timetics
6 */
7 namespace Timetics\Core\Integrations\Woocommerce;
8
9 defined( 'ABSPATH' ) || exit;
10
11 use Timetics\Core\Admin\Notification;
12 use Timetics\Core\Appointments\Appointment;
13 use Timetics\Core\Bookings\Booking;
14 use Timetics\Core\Customers\Customer;
15 use Timetics\Utils\Singleton;
16 use Timetics\Core\Emails\New_Event_Email;
17 use Timetics\Core\Emails\New_Event_Customer_Email;
18 use Timetics\Core\Emails\Cancel_Event_Email;
19 use Timetics\Core\Emails\Cancel_Event_Customer_Email;
20 use Timetics\Core\Integrations\Woocommerce\Status_Mapper;
21
22 /**
23 * Class Hooks
24 */
25 class Hooks {
26 use Singleton;
27
28 /**
29 * Initialize
30 *
31 * @return void
32 */
33 public function init() {
34 Cart_Api::instance();
35
36 add_action( 'woocommerce_init', [$this, 'load_woocommerce_required_files'] );
37
38 add_action( 'woocommerce_thankyou', [$this, 'update_booking_payment_status'] );
39
40 // Sync booking status when WooCommerce order status changes
41 add_action( 'woocommerce_order_status_changed', [$this, 'sync_booking_status_from_order'], 10, 3 );
42
43 add_filter( 'timetics_get_settings', [$this, 'add_woocommerce_currency_support'] );
44
45 add_filter( 'woocommerce_product_query', [$this, 'modify_woocommerce_shop'] );
46
47 add_action( 'woocommerce_coupon_options_usage_restriction', [$this, 'ensure_meeting_products_exist'], 5, 2 );
48 add_action( 'woocommerce_coupon_options_usage_restriction', [$this, 'add_meeting_coupon_restriction_option'], 10, 2 );
49
50 add_action( 'timetics_meeting_after_insert', [$this, 'clear_meeting_products_cache'], 99, 2 );
51 add_action( 'before_delete_post', [$this, 'maybe_clear_cache_on_meeting_delete'], 10, 2 );
52
53 add_action( 'woocommerce_coupon_options_save', [$this, 'save_coupon_option'], 10, 2 );
54
55 add_filter( 'woocommerce_coupon_get_products', [$this, 'assign_meeting_coupon_products'], 10, 2 );
56
57 add_filter( 'timetics_settings', [$this, 'add_wc_checkout_url'] );
58
59 add_filter( 'woocommerce_checkout_fields', [$this, 'hide_checkout_fields'] );
60
61 add_filter( 'woocommerce_checkout_posted_data', [$this, 'modify_order_data'] );
62
63 add_action( 'timetics_after_booking_create', [$this, 'add_product_to_cart'], 10, 4 );
64
65 add_filter( 'timetics_currency', [ $this, 'support_woocommerce_currency' ] );
66
67 add_action( 'admin_init', [ $this, 'create_term_timetics_meeting' ] );
68
69 add_action( 'wp_head', [ $this, 'hide_regular_price_checkout_css' ] );
70
71 // Show meeting details under the product in the cart and on checkout
72 add_filter( 'woocommerce_get_item_data', [ $this, 'add_meeting_details_to_cart_item' ], 10, 2 );
73
74 // Store meeting details on the order line item ( thank you page, emails, my account, admin )
75 add_action( 'woocommerce_checkout_create_order_line_item', [ $this, 'add_meeting_details_to_order_item' ], 10, 4 );
76
77 // Sync order status when booking status changes (bidirectional sync)
78 add_action( 'transition_post_status', [ $this, 'sync_order_status_from_booking' ], 10, 3 );
79 }
80
81 /**
82 * Load all required files and functions
83 *
84 * @return void
85 */
86 public function load_woocommerce_required_files() {
87 if ( ! WC()->is_rest_api_request() ) {
88 return;
89 }
90
91 WC()->frontend_includes();
92
93 if ( null === WC()->cart && function_exists( 'wc_load_cart' ) ) {
94 wc_load_cart();
95 }
96 }
97
98 /**
99 * Add product data on woocommerce product data
100 *
101 * @return void
102 */
103 public function add_product_data_store( $stores ) {
104 $stores['product'] = 'Timetics\Core\Integrations\Woocommerce\Product_Data_Store';
105
106 return $stores;
107 }
108
109 /**
110 * Update booking payment status
111 *
112 * @param integer $order_id
113 *
114 * @return void
115 */
116 public function update_booking_payment_status( $order_id ) {
117
118 $order = wc_get_order( $order_id );
119
120 if ( ! $order->is_paid() ) {
121 // Mark booking as failed if payment was not completed
122 $session_data = WC()->session->get( 'timetics_data' );
123 if ( $session_data ) {
124 Status_Mapper::set_order_id_for_booking( $session_data['booking_id'], $order_id );
125 }
126 WC()->session->set( 'timetics_data', null );
127 return;
128 }
129
130 $session_data = WC()->session->get( 'timetics_data' );
131
132 if ( ! $session_data ) {
133 return;
134 }
135
136 $booking = new Booking( $session_data['booking_id'] );
137
138 // Get the default booking status (e.g., 'approved')
139 $default_booking_status = timetics_get_option( 'default_booking_status', 'approved' );
140
141 $booking->update( [
142 'post_status' => $default_booking_status,
143 'payment_status' => 'completed',
144 'payment_method' => 'woocommerce',
145 ] );
146
147 // Store the order ID in booking meta and booking id in woocommerce order meta for status syncing
148 Status_Mapper::set_order_id_for_booking( $session_data['booking_id'], $order_id );
149 Status_Mapper::set_booking_id_for_order( $order_id, $session_data['booking_id'] );
150
151 $booking->create_event();
152 $is_email_to_customer = timetics_get_option( 'booking_created_customer');
153 $is_email_to_host = timetics_get_option( 'booking_created_host');
154 if( $is_email_to_host ){
155 $new_event_email = new New_Event_Email( $booking );
156 $new_event_email->send();
157 }
158
159 if( $is_email_to_customer ){
160 $new_event_customer_email = new New_Event_Customer_Email( $booking );
161 $new_event_customer_email->send();
162 }
163
164 do_action( 'timetics_gln_hook', 'booking_created', Notification::get_hook_data( $booking ) );
165
166 WC()->session->set( 'timetics_data', null );
167
168 }
169
170 /**
171 * Support woocommerce currency
172 *
173 * @param array $settings
174 *
175 * @return array
176 */
177 public function add_woocommerce_currency_support( $settings ) {
178
179 if ( ! function_exists( 'WC' ) ) {
180 return $settings;
181 }
182
183 $wc_integration = timetics_get_option( 'wc_integration' );
184
185 if ( ! $wc_integration ) {
186 return $settings;
187 }
188
189 $settings['currency'] = get_woocommerce_currency();
190
191 return $settings;
192 }
193
194 /**
195 * Hide timetics meeting from woocommerce shop page
196 *
197 * @param Object $query
198 *
199 * @return Object
200 */
201 public function modify_woocommerce_shop( $query ) {
202 $category_slug = 'timetics-meeting'; // Replace 'category-slug' with the desired category slug
203
204 // Get product category ID
205 $category = get_term_by( 'slug', $category_slug, 'product_cat' );
206
207 if ( $category ) {
208 $category_id = $category->term_id;
209
210 // Exclude products assigned to the category
211 $query->set( 'tax_query', array(
212 array(
213 'taxonomy' => 'product_cat',
214 'field' => 'term_id',
215 'terms' => $category_id,
216 'operator' => 'NOT IN',
217 ),
218 ) );
219 }
220
221 return $query;
222 }
223
224 /**
225 * Set coupon products
226 *
227 * @param integer $coupon_id
228 * @param Object $coupon
229 *
230 * @return void
231 */
232 public function save_coupon_option( $coupon_id, $coupon ) {
233 // Verify nonce for security
234 if ( ! isset( $_POST['timetics_coupon_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['timetics_coupon_nonce'] ) ), 'timetics_coupon_action' ) ) {
235 return;
236 }
237
238 $meeting_ids = ! empty( $_POST['timetics_meeting_ids'] ) ? array_map( 'intval', wp_unslash( $_POST['timetics_meeting_ids'] ) ) : [];
239
240 $coupon_products = $coupon->get_product_ids();
241 $coupon_products = array_merge( $coupon_products, $meeting_ids );
242 $coupon->update_meta_data( 'timetics_meeting_ids', $meeting_ids );
243
244 $coupon->set_product_ids( $coupon_products );
245 $coupon->save();
246 }
247
248 /**
249 * Add woocommerce coupon restriction option
250 *
251 * @param int $coupon_id
252 * @param Object $coupon
253 *
254 * @return void
255 */
256 public function add_meeting_coupon_restriction_option( $coupon_id, $coupon ) {
257 include_once TIMETICS_PLUGIN_DIR . '/templates/woocommerce/coupon-restriction-option.php';
258 }
259
260 /**
261 * Ensure all meeting products exist in WooCommerce
262 *
263 * @param int $coupon_id
264 * @param Object $coupon
265 *
266 * @return void
267 */
268 public function ensure_meeting_products_exist( $coupon_id, $coupon ) {
269 $cache_key = 'timetics_meeting_products_synced';
270 if ( get_transient( $cache_key ) ) {
271 return;
272 }
273
274 if ( ! term_exists( 'timetics-meeting', 'product_cat' ) ) {
275 timetics_add_woocommerce_product_cat();
276 }
277
278 $args = [
279 'post_type' => 'timetics-appointment',
280 'post_status' => 'publish',
281 'posts_per_page' => -1,
282 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering meetings by meta fields
283 'meta_query' => [
284 [
285 'key' => 'wc_product_id',
286 'compare' => 'NOT EXISTS',
287 ],
288 ],
289 ];
290
291 $meetings = get_posts( $args );
292
293 if ( empty( $meetings ) ) {
294 set_transient( $cache_key, true, DAY_IN_SECONDS );
295 return;
296 }
297
298 $product_creator = new Product();
299
300 foreach ( $meetings as $meeting ) {
301 $appointment = new Appointment( $meeting->ID );
302
303 if ( ! $appointment->get_id() ) {
304 continue;
305 }
306
307 if ( empty( $appointment->get_name() ) ) {
308 continue;
309 }
310
311 $product_id = $appointment->get_wc_product_id();
312 if ( ! $product_id ) {
313 $price = $appointment->get_price();
314 if ( is_array( $price ) ) {
315 $price = '';
316 }
317
318 $product_id = $product_creator->create( $appointment );
319 }
320 }
321
322 set_transient( $cache_key, true, DAY_IN_SECONDS );
323 }
324
325 /**
326 * Clear meeting products cache when new meeting is created
327 *
328 * @param mixed $appointment Appointment object or ID
329 * @param mixed $data Meeting data array
330 *
331 * @return void
332 */
333 public function clear_meeting_products_cache( $appointment = null, $data = null ) {
334 delete_transient( 'timetics_meeting_products_synced' );
335 }
336
337 /**
338 * Clear meeting products cache when a meeting is deleted
339 *
340 * @param int $post_id
341 * @param Object $post
342 *
343 * @return void
344 */
345 public function maybe_clear_cache_on_meeting_delete( $post_id, $post ) {
346 if ( 'timetics-appointment' === $post->post_type ) {
347 delete_transient( 'timetics_meeting_products_synced' );
348 }
349 }
350
351 /**
352 * Add checkout url on settings
353 *
354 * @param array $settings
355 *
356 * @return array
357 */
358 public function add_wc_checkout_url( $settings ) {
359 if ( ! timetics_is_woocommerce_active() ) {
360 return $settings;
361 }
362
363 $settings['wc_checkout_url'] = wc_get_checkout_url();
364
365 return $settings;
366 }
367
368 /**
369 * Hide all fields from checkout page
370 *
371 * @param array $fields
372 *
373 * @return array
374 */
375 public function hide_checkout_fields( $fields ) {
376 if ( ! function_exists( 'WC' ) || ! WC()->session ) {
377 return $fields;
378 }
379
380 $session_data = WC()->session->get( 'timetics_data' );
381
382 if ( ! $session_data ) {
383 return $fields;
384 }
385
386 $fields['billing'] = array();
387
388 // Hide all shipping fields
389 $fields['shipping'] = array();
390
391 $fields['order'] = array();
392
393 return $fields;
394 }
395
396 /**
397 * Modify order posted data
398 *
399 * @param array $data
400 *
401 * @return array
402 */
403 public function modify_order_data( $data ) {
404 if ( ! function_exists( 'WC' ) || ! WC()->session ) {
405 return $data;
406 }
407
408 $session_data = WC()->session->get( 'timetics_data' );
409
410 if ( ! $session_data ) {
411 return $data;
412 }
413
414 $booking = new Booking( $session_data['booking_id'] );
415 $customer = new Customer( $booking->get_customer_id() );
416
417 $first_name = $customer->get_first_name();
418 $last_name = $customer->get_last_name();
419 $email = $customer->get_email();
420 $phone = $customer->get_phone();
421
422 if ( $first_name ) {
423 $data['billing_first_name'] = $first_name;
424 }
425
426 if ( $last_name ) {
427 $data['billing_last_name'] = $last_name;
428 }
429
430 if ( $email ) {
431 $data['billing_email'] = $email;
432 }
433
434 if ( $phone ) {
435 $data['billing_phone'] = $phone;
436 }
437
438 return $data;
439 }
440
441 /**
442 * Add to cart timetics appointment
443 *
444 * @param integer $booking_id
445 * @param integer $customer_id
446 * @param integer $meeting_id
447 * @param array $data
448 *
449 * @return void
450 */
451 public function add_product_to_cart( $booking_id, $customer_id, $meeting_id, $data ) {
452 if ( 'woocommerce' !== $data['payment_method'] ) {
453 return;
454 }
455
456 $booking = new Booking( $booking_id );
457 $meeting_id = $booking->get_appointment();
458 if( !empty($data['seats']) && is_array( $data['seats'])) {
459 $quantity = count( $data['seats'] );
460 }else {
461 $quantity = 1;
462 }
463 $price = $booking->get_total();
464
465 // Get meeting details for checkout display
466 $appointment = new Appointment( $meeting_id );
467 $start_date = $booking->get_start_date();
468 $start_time = $booking->get_start_time();
469 $end_time = $booking->get_end_time();
470 $timezone = $booking->get_timezone();
471 $location = $booking->get_location();
472 $location_type = $booking->get_location_type();
473 $duration = $appointment->get_duration();
474 $meeting_timezone = $appointment->get_timezone();
475
476 // Set session for timetics data for woocommerce.
477 WC()->session->set( 'timetics_data', [
478 'booking_id' => $booking_id,
479 'meeting_id' => $meeting_id,
480 'price' => $price,
481 'start_date' => $start_date,
482 'start_time' => $start_time,
483 'end_time' => $end_time,
484 'timezone' => $timezone,
485 'meeting_timezone' => $meeting_timezone,
486 'location' => $location,
487 'location_type' => $location_type,
488 'duration' => $duration,
489 ] );
490
491 // Remove all items from cart.
492 WC()->cart->empty_cart();
493
494 // Preparing for add to cart.
495 $meeting = new Appointment( $meeting_id );
496 $product_id = $meeting->get_wc_product_id();
497
498
499 if ( ! $product_id ) {
500 $product = new Product();
501 $product_id = $product->create( $meeting );
502 }
503
504 $wc_product = wc_get_product( $product_id );
505 if ( ! $wc_product->get_price() ) {
506 update_post_meta( $product_id, '_regular_price', wc_format_decimal( $price ) );
507 update_post_meta( $product_id, '_price', wc_format_decimal( $price ) );
508 }
509
510 if ( ! $product_id ) {
511 return;
512 }
513
514 WC()->cart->add_to_cart( $product_id, $quantity );
515 }
516
517 /**
518 * Suport woocmmerce currency
519 *
520 * @param string $currency
521 *
522 * @return string
523 */
524 public function support_woocommerce_currency( $currency ) {
525 if ( ! timetics_is_woocommerce_active() ) {
526 return $currency;
527 }
528
529 return get_woocommerce_currency();
530 }
531
532 /**
533 * Create a category if woocommerce loaded
534 *
535 * @return void
536 */
537 public function create_term_timetics_meeting() {
538 if ( term_exists( 'timetics-meeting', 'product_cat' ) ) {
539 return;
540 }
541
542 // Create new woocommerce product category for timetics meeting
543 timetics_add_woocommerce_product_cat();
544 }
545
546 public function hide_regular_price_checkout_css() {
547 $session = WC()->session;
548 if ( (is_checkout() || is_cart()) && $session ) {
549 $timetics_data = $session->get('timetics_data');
550 if( isset($timetics_data) ) {
551 ?>
552 <style type="text/css">
553 .wc-block-components-order-summary .wc-block-components-order-summary-item__individual-prices {
554 display: none;
555 }
556 .wc-block-cart-item__prices,
557 .woocommerce-cart-form__cart-item.cart_item .product-price {
558 display: none;
559 }
560 .wc-block-cart-item__quantity,
561 .woocommerce-cart-form__cart-item.cart_item .product-quantity {
562 display: none;
563 }
564 </style>
565 <?php
566 }
567 }
568 }
569
570 /**
571 * Format and prepare meeting details
572 *
573 * @param array $session_data
574 *
575 * @return array|false
576 */
577 private function prepare_meeting_details( $session_data ) {
578 if ( ! $session_data || ! is_array( $session_data ) ) {
579 return false;
580 }
581
582 $start_date = isset( $session_data['start_date'] ) ? $session_data['start_date'] : '';
583 $start_time = isset( $session_data['start_time'] ) ? $session_data['start_time'] : '';
584 $customer_timezone = isset( $session_data['timezone'] ) ? $session_data['timezone'] : '';
585 $meeting_timezone = isset( $session_data['meeting_timezone'] ) ? $session_data['meeting_timezone'] : '';
586 $duration = isset( $session_data['duration'] ) ? $session_data['duration'] : '';
587 $location_type = isset( $session_data['location_type'] ) ? $session_data['location_type'] : '';
588
589 if ( ! $start_date || ! $start_time ) {
590 return false;
591 }
592
593 // Get WordPress date and time formats
594 $date_format = get_option( 'date_format', 'F j, Y' );
595 $time_format = get_option( 'time_format', 'g:i a' );
596
597 // Convert timezone if both customer and meeting timezones are available
598 $datetime_to_display = null;
599
600 if ( $customer_timezone && $meeting_timezone && timetics_is_valid_timezone( $customer_timezone ) && timetics_is_valid_timezone( $meeting_timezone ) ) {
601 $datetime_to_display = timetics_convert_timezone( $start_date . ' ' . $start_time, $customer_timezone, $meeting_timezone );
602 $display_timezone = $meeting_timezone;
603 } else {
604 $datetime_to_display = new \DateTime( $start_date . ' ' . $start_time );
605 $display_timezone = $customer_timezone ?: 'UTC';
606 }
607
608 $formatted_date = $datetime_to_display->format( $date_format );
609 $formatted_time = $datetime_to_display->format( $time_format );
610
611 // Build location type label
612 $location_label = '';
613 if ( $location_type ) {
614 $location_label = ucfirst( str_replace( '-', ' ', $location_type ) );
615 }
616
617 return [
618 'formatted_date' => $formatted_date,
619 'formatted_time' => $formatted_time,
620 'duration' => $duration,
621 'location_label' => $location_label,
622 'timezone' => $customer_timezone,
623 'display_timezone' => $display_timezone,
624 ];
625 }
626
627 /**
628 * Meeting details as label => value pairs, ready to display.
629 *
630 * Shared by the cart / checkout item data and the order line item meta so the
631 * customer sees the same details before and after paying.
632 *
633 * @param array $session_data
634 *
635 * @return array
636 */
637 private function get_meeting_detail_pairs( $session_data ) {
638 $details = $this->prepare_meeting_details( $session_data );
639
640 if ( ! $details ) {
641 return [];
642 }
643
644 $time = $details['formatted_time'];
645
646 if ( $time && $details['display_timezone'] ) {
647 $time .= ' (' . $details['display_timezone'] . ')';
648 }
649
650 return array_filter( [
651 __( 'Date', 'timetics' ) => $details['formatted_date'],
652 __( 'Time', 'timetics' ) => $time,
653 __( 'Duration', 'timetics' ) => $details['duration'],
654 __( 'Location', 'timetics' ) => $details['location_label'],
655 __( 'Timezone', 'timetics' ) => $details['timezone'],
656 ] );
657 }
658
659 /**
660 * Whether the given product is the meeting product this session booked.
661 *
662 * The cart can hold other products, so the details must only be attached to
663 * the meeting line.
664 *
665 * @param array $session_data
666 * @param integer $product_id
667 *
668 * @return bool
669 */
670 private function is_meeting_product( $session_data, $product_id ) {
671 if ( empty( $session_data['meeting_id'] ) ) {
672 return false;
673 }
674
675 $meeting = new Appointment( $session_data['meeting_id'] );
676
677 return (int) $meeting->get_wc_product_id() === (int) $product_id;
678 }
679
680 /**
681 * Show meeting details under the product in the cart and on checkout.
682 *
683 * WooCommerce runs this filter for both the classic templates and the Store API,
684 * so one filter covers the classic and the block based cart / checkout. The block
685 * checkout is the default since WooCommerce 8.3, and the old
686 * woocommerce_review_order_before_payment hook this replaced never ran there.
687 *
688 * @param array $item_data
689 * @param array $cart_item
690 *
691 * @return array
692 */
693 public function add_meeting_details_to_cart_item( $item_data, $cart_item ) {
694 $session_data = WC()->session ? WC()->session->get( 'timetics_data' ) : null;
695
696 if ( ! $session_data || ! $this->is_meeting_product( $session_data, $cart_item['product_id'] ) ) {
697 return $item_data;
698 }
699
700 foreach ( $this->get_meeting_detail_pairs( $session_data ) as $label => $value ) {
701 $item_data[] = [
702 'key' => $label,
703 'value' => $value,
704 ];
705 }
706
707 return $item_data;
708 }
709
710 /**
711 * Store meeting details as order line item meta.
712 *
713 * The booking details only live in the WooCommerce session, which is cleared
714 * once the order is placed. Copying them onto the line item makes WooCommerce
715 * render them everywhere an order is shown: the thank you page, the customer
716 * and admin order emails, My Account -> View order, and the admin order screen.
717 *
718 * @param \WC_Order_Item_Product $item
719 * @param string $cart_item_key
720 * @param array $values
721 * @param \WC_Order $order
722 *
723 * @return void
724 */
725 public function add_meeting_details_to_order_item( $item, $cart_item_key, $values, $order ) {
726 $session_data = WC()->session ? WC()->session->get( 'timetics_data' ) : null;
727
728 if ( ! $session_data || ! $this->is_meeting_product( $session_data, $item->get_product_id() ) ) {
729 return;
730 }
731
732 foreach ( $this->get_meeting_detail_pairs( $session_data ) as $label => $value ) {
733 $item->add_meta_data( $label, $value, true );
734 }
735
736 // Link the order back to the booking here rather than on woocommerce_thankyou.
737 // The thank you page only runs for a customer who actually lands on it and, for
738 // an unpaid order, only stored the booking -> order half of the link. Without the
739 // order -> booking half, sync_booking_status_from_order() finds no booking and an
740 // admin later marking the order paid or cancelled silently does nothing.
741 //
742 // The order is not saved here on purpose: in the classic checkout the order has no
743 // ID yet at this point. WooCommerce saves it right after building the line items.
744 if ( ! empty( $session_data['booking_id'] ) ) {
745 $order->update_meta_data( '_tt_booking_id', $session_data['booking_id'] );
746 }
747 }
748
749 /**
750 * Sync booking status when WooCommerce order status changes
751 *
752 * @param int $order_id The WooCommerce order ID
753 * @param string $old_status The previous order status (without 'wc-' prefix)
754 * @param string $new_status The new order status (without 'wc-' prefix)
755 *
756 * @return void
757 */
758 public function sync_booking_status_from_order( $order_id, $old_status, $new_status ) {
759 // Get the booking ID from order meta
760 $booking_id = Status_Mapper::get_booking_id_from_order( $order_id );
761
762 if ( ! $booking_id ) {
763 return;
764 }
765
766 // Check if this pair is already syncing (prevents reverse sync infinite loop)
767 if ( Status_Mapper::is_syncing_pair( $booking_id, $order_id ) ) {
768 return;
769 }
770
771 // Check if sync is needed
772 if ( ! Status_Mapper::should_sync_status( $old_status, $new_status ) ) {
773 return;
774 }
775
776 // Mark this pair as syncing to prevent reverse sync
777 Status_Mapper::begin_sync_pair( $booking_id, $order_id );
778
779 try {
780 $booking = new Booking( $booking_id );
781
782 if ( ! $booking->is_booking() ) {
783 return;
784 }
785
786 // Map WooCommerce status to Timetics booking status
787 $booking_status = Status_Mapper::order_to_booking_status( $new_status );
788
789 // Get current booking status
790 $current_status = $booking->get_status();
791
792 // A cancelled / refunded order is terminal, so release the slot it was
793 // holding and let it appear as free again.
794 //
795 // We deliberately do NOT release on a 'failed' order. A WooCommerce
796 // booking is created in the 'failed' state while awaiting payment, and
797 // a failed order is retryable — the customer can re-pay. Releasing on
798 // 'failed' would free the slot, and a successful retry
799 // ( failed -> processing/approved ) would then revive the booking
800 // without re-holding the slot, causing an overbooking. A genuinely
801 // abandoned order is cancelled by WooCommerce's own unpaid-order
802 // handling ( hold-stock timeout ), which maps to 'cancel' and releases
803 // here.
804 //
805 // This runs before the "status unchanged" short-circuit below so that a
806 // refund after an already-cancelled booking ( cancel -> cancel,
807 // unchanged ) still guarantees a release. release_slot() is idempotent.
808 if ( 'cancel' === $booking_status ) {
809 $booking->release_slot();
810 }
811
812 // Only update if status has actually changed
813 if ( $current_status === $booking_status ) {
814 return;
815 }
816
817 // Update booking status
818 // This will trigger transition_post_status, but our pair lock prevents reverse sync
819 $booking->update( [ 'post_status' => $booking_status ] );
820
821 // Send cancellation emails if status changed to 'cancel'
822 if ( 'cancel' === $booking_status ) {
823 $booking->delete_event();
824
825 $is_email_to_customer = timetics_get_option( 'booking_canceled_customer' );
826 $is_email_to_host = timetics_get_option( 'booking_canceled_host' );
827
828 if ( $is_email_to_host ) {
829 $cancel_event_email = new Cancel_Event_Email( $booking );
830 $cancel_event_email->send();
831 }
832
833 if ( $is_email_to_customer ) {
834 $customer_cancel_event_email = new Cancel_Event_Customer_Email( $booking );
835 $customer_cancel_event_email->send();
836 }
837
838 do_action( 'timetics_gln_hook', 'booking_canceled', Notification::get_hook_data( $booking ) );
839 }
840
841 } finally {
842 // Always clear the sync pair flag
843 Status_Mapper::end_sync_pair( $booking_id, $order_id );
844 }
845 }
846
847 /**
848 * Sync WooCommerce order status when booking status changes
849 *
850 * @param string $new_status The new booking status
851 * @param string $old_status The previous booking status
852 * @param WP_Post $post The booking post object
853 *
854 * @return void
855 */
856 public function sync_order_status_from_booking( $new_status, $old_status, $post ) {
857 // Only process timetics-booking posts
858 if ( 'timetics-booking' !== $post->post_type ) {
859 return;
860 }
861
862 $booking_id = $post->ID;
863
864 $order_id = Status_Mapper::get_order_id_from_booking( $booking_id );
865
866 if ( ! $order_id ) {
867 return;
868 }
869
870 if ( Status_Mapper::is_syncing_pair( $booking_id, $order_id ) ) {
871 return;
872 }
873
874 if ( ! Status_Mapper::should_sync_status( $old_status, $new_status ) ) {
875 return;
876 }
877
878 Status_Mapper::begin_sync_pair( $booking_id, $order_id );
879
880 try {
881 $order = wc_get_order( $order_id );
882
883 if ( ! $order ) {
884 return;
885 }
886
887 // Map Timetics booking status to WooCommerce order status
888 $wc_status = Status_Mapper::booking_to_order_status( $new_status );
889
890 // Get current order status (without 'wc-' prefix)
891 $current_wc_status = str_replace( 'wc-', '', $order->get_status() );
892
893 if ( $current_wc_status === $wc_status ) {
894 return;
895 }
896
897 $order->set_status( $wc_status );
898 $order->save();
899
900 } finally {
901 // Always clear the sync pair flag
902 Status_Mapper::end_sync_pair( $booking_id, $order_id );
903 }
904 }
905 }
906