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
← All changes | core/integrations/woocommerce/hooks.php +546 -6 1.0.10 → 1.0.64 View file →
@@ -5,12 +5,20 @@
5 5 * @package Timetics
6 6 */
7 7 namespace Timetics\Core\Integrations\Woocommerce;
8 8
9 +defined( 'ABSPATH' ) || exit;
10 +
11 +use Timetics\Core\Admin\Notification;
9 12 use Timetics\Core\Appointments\Appointment;
10 13 use Timetics\Core\Bookings\Booking;
11 14 use Timetics\Core\Customers\Customer;
12 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;
13 21
14 22 /**
15 23 * Class Hooks
16 24 */
@@ -28,14 +36,21 @@
28 36 add_action( 'woocommerce_init', [$this, 'load_woocommerce_required_files'] );
29 37
30 38 add_action( 'woocommerce_thankyou', [$this, 'update_booking_payment_status'] );
31 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 +
32 43 add_filter( 'timetics_get_settings', [$this, 'add_woocommerce_currency_support'] );
33 44
34 45 add_filter( 'woocommerce_product_query', [$this, 'modify_woocommerce_shop'] );
35 46
47 + add_action( 'woocommerce_coupon_options_usage_restriction', [$this, 'ensure_meeting_products_exist'], 5, 2 );
36 48 add_action( 'woocommerce_coupon_options_usage_restriction', [$this, 'add_meeting_coupon_restriction_option'], 10, 2 );
37 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 +
38 53 add_action( 'woocommerce_coupon_options_save', [$this, 'save_coupon_option'], 10, 2 );
39 54
40 55 add_filter( 'woocommerce_coupon_get_products', [$this, 'assign_meeting_coupon_products'], 10, 2 );
41 56
@@ -49,8 +64,19 @@
49 64
50 65 add_filter( 'timetics_currency', [ $this, 'support_woocommerce_currency' ] );
51 66
52 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 );
53 79 }
54 80
55 81 /**
56 82 * Load all required files and functions
@@ -91,8 +117,13 @@
91 117
92 118 $order = wc_get_order( $order_id );
93 119
94 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 + }
95 126 WC()->session->set( 'timetics_data', null );
96 127 return;
97 128 }
98 129
@@ -103,14 +134,36 @@
103 134 }
104 135
105 136 $booking = new Booking( $session_data['booking_id'] );
106 137
138 + // Get the default booking status (e.g., 'approved')
139 + $default_booking_status = timetics_get_option( 'default_booking_status', 'approved' );
140 +
107 141 $booking->update( [
108 - 'post_status' => 'completed',
142 + 'post_status' => $default_booking_status,
109 143 'payment_status' => 'completed',
110 144 'payment_method' => 'woocommerce',
111 145 ] );
112 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 +
113 166 WC()->session->set( 'timetics_data', null );
114 167
115 168 }
116 169
@@ -176,10 +229,15 @@
176 229 *
177 230 * @return void
178 231 */
179 232 public function save_coupon_option( $coupon_id, $coupon ) {
180 - $meeting_ids = ! empty( $_POST['timetics_meeting_ids'] ) ? array_map( 'intval', $_POST['timetics_meeting_ids'] ) : [];
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 + }
181 237
238 + $meeting_ids = ! empty( $_POST['timetics_meeting_ids'] ) ? array_map( 'intval', wp_unslash( $_POST['timetics_meeting_ids'] ) ) : [];
239 +
182 240 $coupon_products = $coupon->get_product_ids();
183 241 $coupon_products = array_merge( $coupon_products, $meeting_ids );
184 242 $coupon->update_meta_data( 'timetics_meeting_ids', $meeting_ids );
185 243
@@ -199,8 +257,99 @@
199 257 include_once TIMETICS_PLUGIN_DIR . '/templates/woocommerce/coupon-restriction-option.php';
200 258 }
201 259
202 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 + /**
203 352 * Add checkout url on settings
204 353 *
205 354 * @param array $settings
206 355 *
@@ -223,8 +372,12 @@
223 372 *
224 373 * @return array
225 374 */
226 375 public function hide_checkout_fields( $fields ) {
376 + if ( ! function_exists( 'WC' ) || ! WC()->session ) {
377 + return $fields;
378 + }
379 +
227 380 $session_data = WC()->session->get( 'timetics_data' );
228 381
229 382 if ( ! $session_data ) {
230 383 return $fields;
@@ -247,8 +400,12 @@
247 400 *
248 401 * @return array
249 402 */
250 403 public function modify_order_data( $data ) {
404 + if ( ! function_exists( 'WC' ) || ! WC()->session ) {
405 + return $data;
406 + }
407 +
251 408 $session_data = WC()->session->get( 'timetics_data' );
252 409
253 410 if ( ! $session_data ) {
254 411 return $data;
@@ -297,16 +454,39 @@
297 454 }
298 455
299 456 $booking = new Booking( $booking_id );
300 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 + }
301 463 $price = $booking->get_total();
302 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();
303 475
304 476 // Set session for timetics data for woocommerce.
305 477 WC()->session->set( 'timetics_data', [
306 - 'booking_id' => $booking_id,
307 - 'meeting_id' => $meeting_id,
308 - 'price' => $price,
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,
309 489 ] );
310 490
311 491 // Remove all items from cart.
312 492 WC()->cart->empty_cart();
@@ -313,10 +493,10 @@
313 493
314 494 // Preparing for add to cart.
315 495 $meeting = new Appointment( $meeting_id );
316 496 $product_id = $meeting->get_wc_product_id();
317 - $quantity = 1;
318 497
498 +
319 499 if ( ! $product_id ) {
320 500 $product = new Product();
321 501 $product_id = $product->create( $meeting );
322 502 }
@@ -360,6 +540,366 @@
360 540 }
361 541
362 542 // Create new woocommerce product category for timetics meeting
363 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 + }
364 904 }
365 905 }