PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / 1.0.62
Timetics – Appointment Booking Calendar & Scheduling v1.0.62
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 1.0.22 All 64 releases
timetics / core / bookings / api-booking.php

api-booking.php in Timetics – Appointment Booking Calendar & Scheduling 1.0.62, at core/bookings/api-booking.php

1,846 lines 71.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Booking api
4 *
5 * @package Timetics
6 */
7 namespace Timetics\Core\Bookings;
8
9 defined( 'ABSPATH' ) || exit;
10
11 use Error;
12 use Timetics\Base\Api;
13 use Timetics\Core\Appointments\Api_Appointment;
14 use Timetics\Core\Appointments\Appointment;
15 use Timetics\Core\Customers\Customer;
16 use Timetics\Core\Admin\Notification;
17 use Timetics\Core\Admin\Notification_Flow_Guard;
18 use Timetics\Core\Emails\Cancel_Event_Customer_Email;
19 use Timetics\Core\Emails\Cancel_Event_Email;
20 use Timetics\Core\Emails\New_Event_Customer_Email;
21 use Timetics\Core\Emails\New_Event_Email;
22 use Timetics\Core\Emails\Update_Event_Customer_Email;
23 use Timetics\Core\Emails\Update_Event_Email;
24 use Timetics\Core\Integrations\Stripe\StripePayment;
25 use Timetics\Core\Staffs\Staff;
26 use Timetics\Utils\Singleton;
27 use TimeticsPro\Core\SeatPlan\SeatPlan;
28 use WP_Error;
29 use WP_HTTP_Response;
30 use WP_Query;
31
32 class Api_Booking extends Api {
33 use Singleton;
34
35 /**
36 * Store api namespace
37 *
38 * @var string
39 */
40 protected $namespace = 'timetics/v1';
41
42 /**
43 * Store rest base
44 *
45 * @var string
46 */
47 protected $rest_base = 'bookings';
48
49 /**
50 * Booking Type
51 *
52 * @var string
53 */
54 protected $type = '';
55
56 /**
57 * Register rest routes
58 *
59 * @return void
60 */
61 public function register_routes() {
62 /**
63 * Register route
64 *
65 * @var void
66 */
67 register_rest_route(
68 $this->namespace, $this->rest_base, [
69 [
70 'methods' => \WP_REST_Server::READABLE,
71 'callback' => [$this, 'get_items'],
72 'permission_callback' => function () {
73 return current_user_can( 'manage_timetics' );
74 },
75 ],
76 [
77 'methods' => \WP_REST_Server::CREATABLE,
78 'callback' => [$this, 'create_item'],
79 'permission_callback' => function () {
80 return true;
81 },
82 ],
83 [
84 'methods' => \WP_REST_Server::DELETABLE,
85 'callback' => [$this, 'bulk_delete'],
86 'permission_callback' => function () {
87 return current_user_can( 'edit_booking' );
88 },
89 ],
90 ]
91 );
92
93 /**
94 * Register route
95 *
96 * @var void
97 */
98 register_rest_route(
99 $this->namespace, '/' . $this->rest_base . '/(?P<booking_id>[\d]+)', [
100 [
101 'methods' => \WP_REST_Server::READABLE,
102 'callback' => [$this, 'get_item'],
103 'permission_callback' => [$this, 'get_item_permission_callback'],
104 ],
105 [
106 'methods' => \WP_REST_Server::EDITABLE,
107 'callback' => [$this, 'update_item'],
108 'permission_callback' => [$this, 'update_item_permission_callback'],
109 ],
110 [
111 'methods' => \WP_REST_Server::DELETABLE,
112 'callback' => [$this, 'delete_item'],
113 'permission_callback' => function () {
114 return current_user_can( 'edit_booking' );
115 },
116 ],
117 ]
118 );
119
120 register_rest_route(
121 $this->namespace, '/' . $this->rest_base . '/(?P<booking_id>[\d]+)/payment', [
122 [
123 'methods' => \WP_REST_Server::EDITABLE,
124 'callback' => [$this, 'make_payment'],
125 'permission_callback' => [$this, 'make_payment_permission_callback'],
126 ],
127 ]
128 );
129
130 register_rest_route(
131 $this->namespace, '/' . $this->rest_base . '/(?P<booking_id>[\d]+)/payment-intent', [
132 [
133 'methods' => \WP_REST_Server::CREATABLE,
134 'callback' => [$this, 'bind_payment_intent'],
135 'permission_callback' => [$this, 'make_payment_permission_callback'],
136 ],
137 ]
138 );
139
140 register_rest_route(
141 $this->namespace, $this->rest_base . '/search', [
142 [
143 'methods' => \WP_REST_Server::READABLE,
144 'callback' => [$this, 'search_items'],
145 'permission_callback' => function () {
146 // edit_booking is admin-only in this plugin (see get_items()) —
147 // staff need manage_timetics to search their own bookings at all.
148 return current_user_can( 'manage_timetics' ) || current_user_can( 'manage_options' );
149 },
150 ],
151 ]
152 );
153
154 register_rest_route(
155 $this->namespace, $this->rest_base . '/entries', [
156 [
157 'methods' => \WP_REST_Server::READABLE,
158 'callback' => [$this, 'get_entries'],
159 'permission_callback' => function () {
160 return true;
161 },
162 ],
163 ]
164 );
165
166 register_rest_route(
167 $this->namespace, $this->rest_base . '/payment_methods', [
168 [
169 'methods' => \WP_REST_Server::READABLE,
170 'callback' => [$this, 'get_payment_methods'],
171 'permission_callback' => function () {
172 return true;
173 },
174 ],
175 ]
176 );
177 }
178
179 /**
180 * Get all bookings
181 *
182 * @param WP_Rest_Request $request
183 *
184 * @return JSON
185 */
186 public function get_items( $request ) {
187 $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 20;
188 $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
189 $meeting_id = ! empty( $request['meeting_id'] ) ? intval( $request['meeting_id'] ) : 0;
190 $start_date = ! empty( $request['start_date'] ) ? $request['start_date'] : '';
191
192 $args = [
193 'posts_per_page' => $per_page,
194 'paged' => $paged,
195 'meeting' => $meeting_id,
196 ];
197
198 $args = apply_filters( 'timetics/add/item/data', $args, $request );
199
200 if ( $start_date ) {
201 $args['start_date'] = $start_date;
202 }
203
204 if ( ! current_user_can( 'manage_options' ) ) {
205 $allowed_ids = Booking::get_visible_ids_for_user( get_current_user_id() );
206 $args['post__in'] = ! empty( $allowed_ids ) ? $allowed_ids : [ 0 ];
207 }
208
209 $bookings = Booking::all( $args );
210 $items = [];
211
212 foreach ( $bookings['items'] as $item ) {
213 $items[] = $this->prepare_item( $item->ID, false );
214 }
215
216 /**
217 * Added temporary for leagacy sass. It will remove in future.
218 */
219 $items = apply_filters( 'timetics/admin/booking/get_items', $items );
220
221 $data = [
222 'success' => 1,
223 'status_code' => 200,
224 'data' => [
225 'total' => $bookings['total'],
226 'items' => $items,
227 ],
228 ];
229
230 return rest_ensure_response( $data );
231 }
232
233 /**
234 * Get single booking
235 *
236 * @param WP_Rest_Request $request
237 *
238 * @return JSON
239 */
240 public function get_item( $request ) {
241 $booking_id = (int) $request['booking_id'];
242 $booking = new Booking( $booking_id );
243
244 if ( ! $booking->is_booking() ) {
245 return [
246 'success' => 0,
247 'status_code' => 404,
248 'message' => esc_html__( 'Invalid booking id.', 'timetics' ),
249 'data' => [],
250 ];
251 }
252
253 /**
254 * Added temporary for leagacy sass. It will remove in future.
255 */
256 do_action( 'timetics/admin/booking/get_item', $this->prepare_item( $booking ) );
257
258 $data = [
259 'success' => 1,
260 'status_code' => 200,
261 'data' => $this->prepare_item( $booking ),
262 ];
263
264 return rest_ensure_response( $data );
265 }
266
267 /**
268 * Create booking
269 *
270 * @param WP_Rest_Request $request
271 *
272 * @return JSON
273 */
274 public function create_item( $request ) {
275
276 $bookings_count = Booking::all();
277
278 $response = [
279 'success' => 0,
280 'status_code' => 502,
281 'message' => esc_html__( 'Something went wrong', 'timetics' ),
282 'data' => [],
283 ];
284
285 if ( apply_filters( 'timetics/staff/booking/count_check', false, $bookings_count ) == true ) {
286 return new WP_HTTP_Response( apply_filters( 'timetics/admin/booking/error_data', $response, 'count_check' ), 403 );
287 }
288
289 $data = json_decode( $request->get_body(), true );
290
291 if ( apply_filters( 'timetics/booking/appointment/type_check', false, $request ) == true ) {
292 return new WP_HTTP_Response( apply_filters( 'timetics/admin/booking/error_data', $response, 'type_check' ), 403 );
293 }
294
295 $recurring_booking = ! empty( $data['recurring_dates'] ) ? $data['recurring_dates'] : [];
296
297 if ( $recurring_booking && apply_filters( 'timetics/booking/appointment/recurring_check', false, $recurring_booking ) == true ) {
298 $response = [
299 'status_code' => 403,
300 'success' => 0,
301 'message' => esc_html__( 'Recurring booking limit exit', 'timetics' ),
302 ];
303
304 return new WP_HTTP_Response( $response, 403 );
305 } // End.
306
307 return $this->save_bookings( $request );
308 }
309
310 /**
311 * Update booking
312 *
313 * @param WP_Rest_Request $request
314 *
315 * @return JSON
316 */
317 public function update_item( $request ) {
318
319 $booking_id = (int) $request['booking_id'];
320 $booking = new Booking( $booking_id );
321
322 if ( ! $booking->is_booking() ) {
323 return [
324 'status_code' => 404,
325 'message' => esc_html__( 'Invalid booking id.', 'timetics' ),
326 'data' => [],
327 ];
328 }
329
330 if ( apply_filters( 'timetics/booking/appointment/custom_form_data', false, $request ) == true ) {
331 $response = [
332 'status_code' => 409,
333 'success' => 0,
334 'message' => esc_html__( 'Custom Field Booking Restricted ', 'timetics' ),
335 ];
336
337 return new WP_HTTP_Response( $response, 403 );
338 }
339
340 return $this->save_bookings( $request, $booking_id );
341 }
342
343 /**
344 * Delete booking
345 *
346 * @param WP_Rest_Request $request
347 *
348 * @return JSON
349 */
350 public function delete_item( $request ) {
351
352 $booking_id = (int) $request['booking_id'];
353
354 $delete = $this->delete( $booking_id );
355
356 if ( ! $delete ) {
357 $data = [
358 'success' => 1,
359 'status_code' => 409,
360 'message' => esc_html__( 'Something went wrong, Please try again.', 'timetics' ),
361 'data' => [],
362 ];
363
364 return new WP_HTTP_Response( $data, 409 );
365 }
366
367 $data = [
368 'success' => 1,
369 'status_code' => 200,
370 'message' => esc_html__( 'Successfully deleted booking', 'timetics' ),
371 'data' => [],
372 ];
373
374 return rest_ensure_response( $data );
375 }
376
377 /**
378 * Delete multiples
379 *
380 * @param WP_Rest_Request $request
381 *
382 * @return JSON
383 */
384 public function bulk_delete( $request ) {
385
386 $bookings = json_decode( $request->get_body(), true );
387
388 foreach ( $bookings as $booking ) {
389 $delete = $this->delete( $booking );
390
391 if ( ! $delete ) {
392 return [
393 'success' => 0,
394 'status_code' => 404,
395 'message' => esc_html__( 'Invalid booking id.', 'timetics' ),
396 'data' => [],
397 ];
398 }
399 }
400
401 /**
402 * Added temporary for leagacy sass. It will remove in future.
403 */
404 do_action( 'timetics/admin/booking/bulk_delete', $bookings );
405
406 return [
407 'success' => 1,
408 'status_code' => 200,
409 'message' => esc_html__( 'Successfully deleted booking', 'timetics' ),
410 ];
411 }
412
413 /**
414 * Get payment methods
415 *
416 * @return array
417 */
418 public function get_payment_methods() {
419
420 $payment_methods = timetics_get_payment_methods();
421
422 return [
423 'success' => 1,
424 'status_code' => 200,
425 'data' => $payment_methods,
426 ];
427 }
428
429 /**
430 * Search bookings
431 *
432 * @param WP_Rest_Request $request
433 *
434 * @return JSON
435 */
436 public function search_items( $request ) {
437
438 // Prepare search args.
439 $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 20;
440 $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
441 $search = ! empty( $request['search'] ) ? sanitize_text_field( $request['search'] ) : '';
442
443 $query_args = array(
444 'post_type' => 'timetics-booking',
445 'posts_per_page' => $per_page,
446 'paged' => $paged,
447 'post_status' => 'any',
448 );
449
450 if ( ! current_user_can( 'manage_options' ) ) {
451 $allowed_ids = Booking::get_visible_ids_for_user( get_current_user_id() );
452 $query_args['post__in'] = ! empty( $allowed_ids ) ? $allowed_ids : [ 0 ];
453 }
454
455 // Get search.
456 $booking = new WP_Query(
457 array_merge(
458 $query_args,
459 array(
460 // @codingStandardsIgnoreStart
461 'meta_query' => array(
462 'relation' => 'OR',
463 array(
464 'key' => '_tt_booking_customer_fname',
465 'value' => $search,
466 'compare' => 'LIKE',
467 ),
468 array(
469 'key' => '_tt_booking_customer_lname',
470 'value' => $search,
471 'compare' => 'LIKE',
472 ),
473 array(
474 'key' => '_tt_booking_customer_email',
475 'value' => $search,
476 'compare' => 'LIKE',
477 ),
478 array(
479 'key' => '_tt_booking_customer_phone',
480 'value' => $search,
481 'compare' => 'LIKE',
482 ),
483 array(
484 'key' => '_tt_booking_staff_fname',
485 'value' => $search,
486 'compare' => 'LIKE',
487 ),
488 array(
489 'key' => '_tt_booking_staff_lname',
490 'value' => $search,
491 'compare' => 'LIKE',
492 ),
493 array(
494 'key' => '_tt_booking_staff_email',
495 'value' => $search,
496 'compare' => 'LIKE',
497 ),
498 array(
499 'key' => '_tt_booking_meeting_name',
500 'value' => $search,
501 'compare' => 'LIKE',
502 ),
503 array(
504 'key' => '_tt_booking_meeting_description',
505 'value' => $search,
506 'compare' => 'LIKE',
507 ),
508 array(
509 'key' => '_tt_booking_meeting_type',
510 'value' => $search,
511 'compare' => 'LIKE',
512 ),
513 ),
514 // @codingStandardsIgnoreEnd
515 )
516 )
517 );
518
519 // Prepare items for response.
520 $items = [];
521
522 foreach ( $booking->posts as $item ) {
523 $items[] = $this->prepare_item( $item->ID, false );
524 }
525
526 /**
527 * Added temporary for leagacy sass. It will remove in future.
528 */
529 $items = apply_filters( 'timetics/admin/booking/search_items', $items );
530
531 $data = [
532 'success' => 1,
533 'status' => 200,
534 'data' => [
535 'total' => $booking->found_posts,
536 'items' => $items,
537 ],
538 ];
539
540 return rest_ensure_response( $data );
541 }
542
543 /**
544 * Get all booking entries
545 *
546 * @param WP_Rest_Request $request
547 *
548 * @return JSON
549 */
550 public function get_entries( $request ) {
551 $staff_id = ! empty( $request['staff_id'] ) ? intval( $request['staff_id'] ) : 0;
552 $meeting_id = ! empty( $request['meeting_id'] ) ? intval( $request['meeting_id'] ) : 0;
553 $start_date = ! empty( $request['start_date'] ) ? sanitize_text_field( $request['start_date'] ) : 0;
554 $timezone = ! empty( $request['timezone'] ) ? sanitize_text_field( $request['timezone'] ) : 0;
555 $end_date = ! empty( $request['end_date'] ) ? sanitize_text_field( $request['end_date'] ) : 0;
556
557 $meeting = new Appointment( $meeting_id );
558
559 // Validate timezone.
560 if ( ! timetics_is_valid_timezone( $timezone ) ) {
561 return new WP_Error( 'timezone_error', __( 'Your booking timezone is invalid', 'timetics' ) );
562 }
563
564 // Validate meeting timezone.
565 if ( ! timetics_is_valid_timezone( $meeting->get_timezone() ) ) {
566 return new WP_Error( 'timezone_error', __( 'Your meeting timezone is invalid. Please update your meeting timezone with proper timezone.', 'timetics' ) );
567 }
568
569 $days = $meeting->prepare_schedule( $start_date, $end_date, $staff_id, $timezone );
570 $days = apply_filters( 'timetics_schedule_data_for_selected_date', $days, $staff_id, $meeting_id, $timezone );
571
572 $data = [
573 'today' => gmdate( 'Y-m-d' ),
574 'availability_timezone' => $meeting->get_timezone(),
575 'days' => $days,
576 ];
577
578 /**
579 * Added temporary for leagacy sass. It will remove in future.
580 */
581 $data = apply_filters( 'timetics/admin/booking/get_entries', $data );
582
583 return [
584 'success' => true,
585 'status_code' => 200,
586 'message' => esc_html__( 'Get all entries', 'timetics' ),
587 'data' => $data,
588 ];
589 }
590
591 /**
592 * Make payment transaction for the current booking
593 *
594 * @param WP_Rest_Request $request
595 *
596 * @return JSON
597 */
598 public function make_payment( $request ) {
599 $booking_id = intval( $request['booking_id'] );
600 $booking = new Booking( $booking_id );
601 $data = json_decode( $request->get_body(), true );
602 $data = is_array( $data ) ? $data : [];
603 $client_status = ! empty( $data['status'] ) ? sanitize_text_field( $data['status'] ) : '';
604 $payment_method = ! empty( $data['payment_method'] ) ? sanitize_text_field( $data['payment_method'] ) : '';
605 $default_booking_status = timetics_get_option( 'default_booking_status', 'approved' );
606 $type = $booking->get_type();
607
608 if ( ! $booking->is_booking() ) {
609 return new WP_HTTP_Response(
610 [
611 'success' => 0,
612 'status_code' => 404,
613 'message' => esc_html__( 'Invalid booking id.', 'timetics' ),
614 ],
615 404
616 );
617 }
618
619 // Idempotency: refuse re-approval of a booking that already finalized.
620 $current_status = (string) $booking->get_status();
621 $finalized_statuses = [ 'approved', 'completed', 'failed', 'cancelled', 'cancel' ];
622 if ( in_array( $current_status, $finalized_statuses, true ) ) {
623 return new WP_HTTP_Response(
624 [
625 'success' => 0,
626 'status_code' => 409,
627 'message' => esc_html__( 'Booking has already been finalized.', 'timetics' ),
628 ],
629 409
630 );
631 }
632
633 $verified_status = 'pending';
634 $payment_details = '';
635 $stored_intent_id = '';
636
637 if ( 'stripe' === $payment_method ) {
638 $client_details = ! empty( $data['payment_details'] ) ? $data['payment_details'] : [];
639 $intent_id = is_array( $client_details ) && ! empty( $client_details['id'] )
640 ? sanitize_text_field( (string) $client_details['id'] )
641 : '';
642
643 if ( '' === $intent_id || strpos( $intent_id, 'pi_' ) !== 0 ) {
644 if ( 'failed' === $client_status ) {
645 $verified_status = 'failed';
646 } else {
647 return new WP_HTTP_Response(
648 [
649 'success' => 0,
650 'status_code' => 400,
651 'message' => esc_html__( 'Missing payment intent.', 'timetics' ),
652 ],
653 400
654 );
655 }
656 } else {
657 $intent = ( new StripePayment() )->retrieve_payment_intent( $intent_id );
658
659 if ( is_wp_error( $intent ) || ! is_array( $intent ) || empty( $intent['id'] ) ) {
660 return new WP_HTTP_Response(
661 [
662 'success' => 0,
663 'status_code' => 502,
664 'message' => esc_html__( 'Cannot verify payment with Stripe.', 'timetics' ),
665 ],
666 502
667 );
668 }
669
670 $expected_amount = (int) round( (float) $booking->get_total() * 100 );
671 $expected_currency = strtolower( (string) apply_filters( 'timetics_currency', timetics_get_option( 'currency', 'USD' ) ) );
672 $intent_status = isset( $intent['status'] ) ? (string) $intent['status'] : '';
673 $intent_amount = isset( $intent['amount'] ) ? (int) $intent['amount'] : 0;
674 $intent_currency = isset( $intent['currency'] ) ? strtolower( (string) $intent['currency'] ) : '';
675 $meta_booking_id = isset( $intent['metadata']['booking_id'] ) ? (int) $intent['metadata']['booking_id'] : 0;
676 $meta_token = isset( $intent['metadata']['security_token'] ) ? (string) $intent['metadata']['security_token'] : '';
677 $stored_token = (string) $booking->get_security_token();
678
679 $mismatch = (
680 'succeeded' !== $intent_status ||
681 $expected_amount !== $intent_amount ||
682 $expected_currency !== $intent_currency ||
683 $booking_id !== $meta_booking_id ||
684 '' === $stored_token ||
685 '' === $meta_token ||
686 ! hash_equals( $stored_token, $meta_token )
687 );
688
689 if ( $mismatch ) {
690 return new WP_HTTP_Response(
691 [
692 'success' => 0,
693 'status_code' => 402,
694 'message' => esc_html__( 'Payment verification failed.', 'timetics' ),
695 ],
696 402
697 );
698 }
699
700 // Replay protection: this booking can be bound to exactly one
701 // PaymentIntent. A second call with a different intent fails.
702 $bound = $booking->get_stripe_payment_intent_id();
703 if ( '' !== $bound && $bound !== $intent['id'] ) {
704 return new WP_HTTP_Response(
705 [
706 'success' => 0,
707 'status_code' => 409,
708 'message' => esc_html__( 'Payment intent does not match this booking.', 'timetics' ),
709 ],
710 409
711 );
712 }
713
714 $stored_intent_id = $intent['id'];
715 $verified_status = 'succeeded';
716 $payment_details = $intent;
717 }
718 } elseif ( 'failed' === $client_status ) {
719 // Marking the user's own attempt as failed never grants access; safe to honor.
720 $verified_status = 'failed';
721 }
722 // Other payment methods (cash, on-site, etc.) stay pending here. They
723 // are approved through their own authenticated/admin paths.
724 $post_status = 'succeeded' === $verified_status
725 ? $default_booking_status
726 : ( 'failed' === $verified_status ? 'failed' : 'pending' );
727
728 $finalizing = 'succeeded' === $verified_status && '' !== $stored_intent_id;
729
730 if ( $finalizing ) {
731 $claimed = add_post_meta( $booking_id, '_tt_stripe_payment_intent_id', $stored_intent_id, true );
732 if ( false === $claimed ) {
733 $existing = (string) get_post_meta( $booking_id, '_tt_stripe_payment_intent_id', true );
734 if ( $existing !== $stored_intent_id ) {
735 return new WP_HTTP_Response(
736 [
737 'success' => 0,
738 'status_code' => 409,
739 'message' => esc_html__( 'Payment intent does not match this booking.', 'timetics' ),
740 ],
741 409
742 );
743 }
744
745 if ( 'pending' !== (string) $booking->get_status() ) {
746 return new WP_HTTP_Response(
747 [
748 'success' => 1,
749 'status_code' => 200,
750 'message' => esc_html__( 'Payment already finalized.', 'timetics' ),
751 ],
752 200
753 );
754 }
755 }
756 }
757
758 $update = $booking->update(
759 [
760 'post_status' => $post_status,
761 'payment_status' => $verified_status,
762 'payment_details' => $payment_details,
763 'payment_method' => $payment_method,
764 ]
765 );
766
767 if ( is_wp_error( $update ) ) {
768 // Roll back the claim so a retry can finalize cleanly.
769 if ( $finalizing ) {
770 delete_post_meta( $booking_id, '_tt_stripe_payment_intent_id', $stored_intent_id );
771 }
772 return new WP_HTTP_Response(
773 [
774 'success' => 0,
775 'status_code' => 409,
776 /* translators: Action */
777 'message' => $update->get_error_message(),
778 ],
779 409
780 );
781 }
782
783 // A failed payment means the booking did not happen, so release the slot
784 // it was holding and let it appear as free again.
785 if ( 'failed' === $post_status ) {
786 $booking->release_slot();
787 }
788
789 if ( $default_booking_status === $post_status ) {
790 // Rotate the security token so the same one cannot drive a second
791 // approval after this booking has finalized.
792 $booking->rotate_security_token();
793
794 $booking->create_event();
795
796 if( 'timetics-event' == $type ){
797 return;
798 }
799
800 $is_email_to_customer = timetics_get_option( 'booking_created_customer');
801 $is_email_to_host = timetics_get_option( 'booking_created_host');
802
803 if ( $is_email_to_host ) {
804 $new_event_email = new New_Event_Email( $booking );
805 $new_event_email->send();
806 }
807
808 if ( $is_email_to_customer ) {
809 $new_event_customer_email = new New_Event_Customer_Email( $booking );
810 $new_event_customer_email->send();
811 }
812
813 do_action( 'timetics_gln_hook', 'booking_created', Notification::get_hook_data( $booking ) );
814
815 do_action( 'timetics_booking_payment', $booking );
816
817 }
818
819 /**
820 * Added temporary for leagacy sass. It will remove in future.
821 */
822 do_action( 'timetics/admin/booking/make_payment', $post_status );
823
824 $data = [
825 'success' => 1,
826 'status_code' => 200,
827 /* translators: Action */
828 'message' => sprintf( esc_html__( 'Payment %s', 'timetics' ), $post_status ),
829 ];
830
831 return new WP_HTTP_Response( $data, 200 );
832 }
833
834 /**
835 * Save booking
836 *
837 * @param WP_Rest_Request $request
838 * @param integer $id Booking id
839 *
840 * @return JSON
841 */
842 public function save_bookings( $request, $id = 0 ) {
843 $data = json_decode( $request->get_body(), true );
844
845 if( isset( $data['type'] ) && 'timetics-event' == $data['type'] ) {
846 $this->type = $data['type'];
847 return apply_filters('timetics_booking_event', $data, $id );
848 }else {
849 return $this->booking_appointment($data, $id);
850 }
851 }
852
853 /**
854 * Booking Appointment
855 *
856 * @param array $data All the data of booking
857 * @param integer $id Booking id
858 *
859 * @return JSON
860 */
861 protected function booking_appointment ($data, $id) {
862 $first_name = ! empty( $data['first_name'] ) ? sanitize_text_field( $data['first_name'] ) : '';
863 $last_name = ! empty( $data['last_name'] ) ? sanitize_text_field( $data['last_name'] ) : '';
864 $email = ! empty( $data['email'] ) ? sanitize_text_field( $data['email'] ) : '';
865 $phone = ! empty( $data['phone'] ) ? sanitize_text_field( $data['phone'] ) : '';
866
867 // Fallback: when built-in phone field absent (e.g., non attendee-call location),
868 // pick phone from custom form field so customer record still gets it.
869 if ( empty( $phone ) && ! empty( $data['custom_form_data'] ) ) {
870 $custom_form = is_array( $data['custom_form_data'] ) ? $data['custom_form_data'] : (array) json_decode( wp_json_encode( $data['custom_form_data'] ), true );
871 foreach ( [ 'phone', 'Phone', 'phone_number', 'mobile', 'contact_number' ] as $key ) {
872 if ( ! empty( $custom_form[ $key ] ) ) {
873 $phone = sanitize_text_field( $custom_form[ $key ] );
874 break;
875 }
876 }
877 }
878 $city = ! empty( $data['city'] ) ? sanitize_text_field( $data['city'] ) : '';
879 $state = ! empty( $data['state'] ) ? sanitize_text_field( $data['state'] ) : '';
880 $post_code = ! empty( $data['post_code'] ) ? sanitize_text_field( $data['post_code'] ) : '';
881 $country = ! empty( $data['country'] ) ? sanitize_text_field( $data['country'] ) : '';
882 $payment_method = ! empty( $data['payment_method'] ) ? sanitize_text_field( $data['payment_method'] ) : '';
883 $address_1 = ! empty( $data['address_1'] ) ? sanitize_text_field( $data['address_1'] ) : '';
884 $address_2 = ! empty( $data['address_2'] ) ? sanitize_text_field( $data['address_2'] ) : '';
885 $appointment = ! empty( $data['appointment'] ) ? intval( $data['appointment'] ) : 0;
886 $staff_id = ! empty( $data['staff'] ) ? intval( $data['staff'] ) : 0;
887 $start_date = ! empty( $data['start_date'] ) ? sanitize_text_field( $data['start_date'] ) : '';
888 $date = ! empty( $data['date'] ) ? sanitize_text_field( $data['date'] ) : '';
889 $end_date = ! empty( $data['end_date'] ) ? sanitize_text_field( $data['end_date'] ) : $start_date;
890 $start_time = ! empty( $data['start_time'] ) ? sanitize_text_field( $data['start_time'] ) : '';
891 $end_time = ! empty( $data['end_time'] ) ? sanitize_text_field( $data['end_time'] ) : '';
892 $client_status = ! empty( $data['status'] ) ? sanitize_text_field( $data['status'] ) : '';
893 $location = ! empty( $data['location'] ) ? sanitize_text_field( $data['location'] ) : '';
894 $location_type = ! empty( $data['location_type'] ) ? sanitize_text_field( $data['location_type'] ) : '';
895 $description = ! empty( $data['description'] ) ? sanitize_text_field( $data['description'] ) : '';
896 $timezone = ! empty( $data['timezone'] ) ? sanitize_text_field( $data['timezone'] ) : '';
897 $recurring_dates = ! empty( $data['recurring_dates'] ) ? $data['recurring_dates'] : [];
898 $seats = ! empty( $data['seats'] ) ? $data['seats'] : [];
899 $cancel_reason = ! empty( $data['cancel_reason'] ) ? $data['cancel_reason'] : [];
900 $booking_time = ! empty( $data['booking_createAt'] ) ? $data['booking_createAt'] : '';
901 $action = $id ? 'updated' : 'created';
902
903 $is_privileged = current_user_can( 'manage_timetics' ) || current_user_can( 'edit_booking' );
904 $server_total = (int) $this->calculate_order_total( $data );
905 $default_status = timetics_get_option( 'default_booking_status', 'approved' );
906 $payment_method_l = strtolower( $payment_method );
907
908 if ( $is_privileged ) {
909 $status = '' !== $client_status ? $client_status : $default_status;
910 } elseif ( 'created' === $action ) {
911 if ( $server_total > 0 && 'stripe' === $payment_method_l ) {
912 $status = 'pending';
913 } elseif ( $server_total > 0 && 'woocommerce' === $payment_method_l ) {
914 $status = 'failed';
915 } else {
916 $status = $default_status;
917 }
918 } else {
919 $current_status = ( new Booking( $id ) )->get_status();
920 if ( 'cancel' === $client_status ) {
921 $status = 'cancel';
922 } else {
923 $status = $current_status;
924 }
925 }
926 $appointment_token = ! empty( $data['appointment_token'] ) ? sanitize_text_field( $data['appointment_token'] ) : '';
927
928 if ( $id ) {
929 $email_validation = $this->validate_email_change_permission( $id, $email );
930
931 if ( is_wp_error( $email_validation ) ) {
932 $error_code = $email_validation->get_error_code();
933 $error_response = [
934 'success' => 0,
935 'status_code' => $error_code,
936 'message' => $email_validation->get_error_message(),
937 ];
938 return new WP_HTTP_Response( $error_response, $error_code );
939 }
940
941 // Use the validated email from the security check
942 $email = $email_validation;
943 }
944
945 $required_fields = [
946 'first_name',
947 'email',
948 'appointment',
949 'start_date',
950 'start_time',
951 'end_time',
952 ];
953
954 // Payment method is only chosen once, at booking creation. Later
955 // updates (status change, reschedule, staff swap, ...) shouldn't have
956 // to resubmit it — requiring it here made admin actions like
957 // cancelling from the calendar popover fail whenever the form didn't
958 // carry the original payment method in its state.
959 if ( 'created' === $action ) {
960 $required_fields[] = 'payment_method';
961 }
962
963 $validate = $this->validate( $data, $required_fields );
964
965 if ( is_wp_error( $validate ) ) {
966 $data = [
967 'status_code' => 403,
968 'success' => 0,
969 'message' => $validate->get_error_messages(),
970 ];
971 return new WP_HTTP_Response( $data, 403 );
972 }
973
974 $customer = new Customer();
975 $meeting = new Appointment( $appointment );
976 $staff = new Staff( $staff_id );
977 $booking = new Booking( $id );
978 $booking_entry = new Booking_Entry();
979
980 // Validate booking
981
982 $validation = $this->validate_booking( $appointment, $data );
983 if(is_wp_error($validation)){
984 return $validation;
985 }
986
987
988
989 if ( 'created' === $action && ! $this->is_available_slot( $meeting, [
990 'staff_id' => $staff->get_id(),
991 'start_date' => $start_date,
992 'start_time' => $start_time,
993 'timezone' => $timezone,
994 ] ) ) {
995 /* translators: %s: Time slot */
996 return new WP_Error( 'time_slot_error', sprintf( __( '%s time slot is not available', 'timetics' ), $start_time ) );
997 }
998
999 if ( $meeting->is_recurring() ) {
1000 $valid_recurrence = apply_filters( 'timetics_validate_recurring_booking', $recurring_dates, $start_time, $staff->get_id(), $meeting->get_id() );
1001
1002 if ( ! $valid_recurrence ) {
1003 $recurring_error = [
1004 'status_code' => 403,
1005 'success' => 0,
1006 'message' => __( 'Couldn\'t possible to book. Plese try another time.', 'timetics' ),
1007 ];
1008
1009 return new WP_HTTP_Response( $recurring_error, 403 );
1010 }
1011 }
1012
1013 $customer->make(
1014 [
1015 'first_name' => $first_name,
1016 'last_name' => $last_name,
1017 'email' => $email,
1018 'phone' => $phone,
1019 ]
1020 );
1021
1022 // Update booking schedule. Release the slot the booking currently holds;
1023 // the new one is taken further below.
1024 if ( $id ) {
1025 // Entries are stored in the meeting's timezone, so the booking's own
1026 // date/time has to be converted before the lookup. Without this the
1027 // entry is missed whenever the two timezones differ and it stays
1028 // behind blocking a slot nobody holds.
1029 $old_meeting = new Appointment( $booking->get_appointment() );
1030 $old_datetime = timetics_convert_timezone(
1031 $booking->get_start_date() . ' ' . $booking->get_start_time(),
1032 $booking->get_timezone(),
1033 $old_meeting->get_timezone()
1034 );
1035
1036 $entries = $booking_entry->find(
1037 [
1038 'staff_id' => $booking->get_staff_id(),
1039 'meeting_id' => $booking->get_appointment(),
1040 'date' => $old_datetime->format( 'Y-m-d' ),
1041 'start' => $old_datetime->format( 'h:i a' ),
1042 ]
1043 );
1044
1045 if ( $entries ) {
1046 $entry = $booking_entry->first();
1047
1048 if ( 'one-to-one' == strtolower( $old_meeting->get_type() ) ) {
1049 $entry->delete();
1050 } else {
1051 $booked = intval( $entry->get_booked() ) - 1;
1052 $booked_data = apply_filters( 'timetics_booking_update_schedule', $entry, ['booked' => $booked], $data, $booking );
1053 $entry->update( $this->normalize_schedule_update( $booked_data, $booked ) );
1054 }
1055 }
1056 }
1057
1058 if ( $id && $booking->get_status() == 'cancel' && $status == 'cancel' ) {
1059 return new WP_Error( 'booking_cancel_error', __( 'This booking alreay canceled', 'timetics' ) );
1060 }
1061
1062 $booking_props = [
1063 'customer' => $customer->get_id(),
1064 'appointment' => $meeting->get_id(),
1065 'appointment_name' => $meeting->get_name(),
1066 'staff' => $staff->get_id(),
1067 'customer_fname' => $customer->get_first_name(),
1068 'customer_lname' => $customer->get_last_name(),
1069 'customer_email' => $customer->get_email(),
1070 'customer_phone' => $customer->get_phone(),
1071 'staff_fname' => $staff->get_first_name(),
1072 'staff_lname' => $staff->get_last_name(),
1073 'staff_email' => $staff->get_email(),
1074 'meeting_name' => $meeting->get_name(),
1075 'meeting_description' => $meeting->get_description(),
1076 'meeting_type' => $meeting->get_type(),
1077 'booking_time' => $booking_time,
1078 'description' => $description,
1079 'start_date' => $start_date,
1080 'date' => $date,
1081 'end_date' => $end_date,
1082 'start_time' => $start_time,
1083 'end_time' => $end_time,
1084 'order_total' => $this->calculate_order_total( $data ),
1085 'post_status' => $status,
1086 'location' => $location,
1087 'location_type' => $location_type,
1088 'timezone' => $timezone,
1089 'cancel_reason' => $cancel_reason,
1090 ];
1091
1092 $old_meeting_timestamp = 0;
1093
1094 if ( $id ) {
1095 $old_start_date = $booking->get_start_date();
1096 $old_start_time = $booking->get_start_time();
1097 $old_end_time = $booking->get_end_time();
1098
1099 // Captured before the props are overwritten so pending delayed
1100 // flows can be matched against the meeting time they were frozen
1101 // with.
1102 $old_meeting_timestamp = Notification::get_booking_timestamp( $booking );
1103 }
1104
1105 if( 'created' == $action ){
1106 $booking_props['security_token'] = $booking->generate_security_token();
1107 }
1108
1109 $booking->set_props( $booking_props );
1110
1111
1112 $booking = apply_filters( 'timetics/bookings/booking/set', $booking );
1113
1114 $booking->save();
1115
1116 // Fire when booking is completed.
1117 do_action( 'timetics_after_booking_create', $booking->get_id(), $customer->get_id(), $meeting->get_id(), $data );
1118
1119 // Note: booking creation emails for new bookings are sent further below,
1120 // AFTER the calendar event is created, so the Google Meet join link is
1121 // available in the email. See the "created" branch after the schedule
1122 // entry is created.
1123
1124 // Create or update calendar event.
1125 if ( $id ) {
1126 if ( 'cancel' === $status ) {
1127 $booking->delete_event();
1128 $is_email_to_customer = timetics_get_option( 'booking_canceled_customer');
1129 $is_email_to_host = timetics_get_option( 'booking_canceled_host');
1130
1131 if ( $is_email_to_host ) {
1132 $cancel_event_email = new Cancel_Event_Email( $booking );
1133 $cancel_event_email->send();
1134 }
1135
1136 if ( $is_email_to_customer ) {
1137 $customer_cancel_event_email = new Cancel_Event_Customer_Email( $booking );
1138 $customer_cancel_event_email->send();
1139 }
1140
1141 do_action( 'timetics_gln_hook', 'booking_canceled', Notification::get_hook_data( $booking ) );
1142
1143 /**
1144 * Added temporary for leagacy sass. It will remove in future.
1145 */
1146 do_action( 'timetics/admin/booking/after_delete_item', $booking );
1147 } else {
1148 // Check if the booking date/time was actually changed
1149 $date_time_changed = (
1150 $old_start_date !== $start_date ||
1151 $old_start_time !== $start_time ||
1152 $old_end_time !== $end_time
1153 );
1154
1155 $booking->update_event();
1156
1157 if ( $date_time_changed ) {
1158 $reschedule_hook_data = Notification::get_hook_data( $booking );
1159
1160 // Move any pending delayed flow onto the new meeting time so
1161 // the reminder keeps its offset instead of firing at the old
1162 // moment with the old details.
1163 Notification_Flow_Guard::reschedule_pending_flows( $booking->get_id(), $reschedule_hook_data );
1164
1165 $is_email_to_reschedule_customer = timetics_get_option( 'booking_rescheduled_customer');
1166 $is_email_to_reschedule_host = timetics_get_option( 'booking_rescheduled_host');
1167
1168 if ( $is_email_to_reschedule_host ) {
1169 $update_event_email = new Update_Event_Email( $booking );
1170 $update_event_email->send();
1171 }
1172
1173 if ( $is_email_to_reschedule_customer ) {
1174 $update_event_customer_email = new Update_Event_Customer_Email( $booking );
1175 $update_event_customer_email->send();
1176 }
1177
1178 // Hand the previous meeting timestamp to the SDK as well —
1179 // its delay node uses `previous_<key>` to drop a checkpoint
1180 // it scheduled itself on an earlier run.
1181 if ( $old_meeting_timestamp ) {
1182 $reschedule_hook_data['previous_meeting_date_timestamp'] = $old_meeting_timestamp;
1183 }
1184
1185 do_action( 'timetics_gln_hook', 'booking_rescheduled', $reschedule_hook_data );
1186 }
1187 }
1188 }
1189
1190 // Convert booking time to staff/meeting time.
1191 $date_time = timetics_convert_timezone( $start_date . ' ' . $start_time, $timezone, $meeting->get_timezone() );
1192 $end_time = timetics_convert_timezone( $start_date . ' ' . $end_time, $timezone, $meeting->get_timezone() );
1193
1194 // Create booking schedule. Skipped on cancel — the slot for this
1195 // booking was already released above, and re-running this block would
1196 // either recreate the just-deleted entry (one-to-one) or double the
1197 // decrement (group), re-blocking or over-freeing the slot.
1198 if ( 'cancel' !== $status ) {
1199 $entries = $booking_entry->find(
1200 [
1201 'staff_id' => $staff->get_id(),
1202 'meeting_id' => $meeting->get_id(),
1203 'date' => $date_time->format( 'Y-m-d' ),
1204 'start' => $date_time->format( 'h:i a' ),
1205 ]
1206 );
1207
1208 if ( $entries ) {
1209 $entry = $booking_entry->first();
1210
1211 $booked = intval( $entry->get_booked() ) + 1;
1212 $booked_data = apply_filters( 'timetics_booking_update_schedule', $entry, ['booked' => $booked], $data, $booking );
1213
1214 $entry->update( $this->normalize_schedule_update( $booked_data, $booked ) );
1215 } else {
1216 $book_entry_data = [
1217 'meeting_id' => $meeting->get_id(),
1218 'staff_id' => $staff->get_id(),
1219 'customer_id' => $customer->get_id(),
1220 'booking_id' => $booking->get_id(),
1221 'booked' => 1,
1222 'date' => $date_time->format( 'Y-m-d' ),
1223 'start' => $date_time->format( 'h:i a' ),
1224 'end' => $end_time->format( 'h:i a' ),
1225 ];
1226
1227 $book_entry_data = apply_filters( 'timetics_booking_schedule', $book_entry_data, $data );
1228 $booking_entry->create( $book_entry_data );
1229 }
1230 }
1231
1232 // For newly created bookings, create the calendar event now that the
1233 // booking schedule entry exists. This generates the Google Meet link
1234 // (stored in booking meta) so it can be shown on the success page and
1235 // included in the notification emails sent below.
1236 if ( 'created' === $action && 'cancel' !== $status ) {
1237 $booking->create_event();
1238 }
1239
1240 // Send booking creation emails for new bookings not processed through
1241 // a separate payment flow. Online gateways (stripe/paypal/woocommerce)
1242 // send this email themselves once payment is finalized, so excluding
1243 // them here avoids a duplicate email for the same booking. Sent here
1244 // (after create_event) so the Google Meet link is present in the email.
1245 if ( 'created' === $action && 'failed' !== $status && ! in_array( $payment_method_l, ['stripe', 'paypal', 'woocommerce'], true ) ) {
1246 $is_email_to_customer = timetics_get_option( 'booking_created_customer');
1247 $is_email_to_host = timetics_get_option( 'booking_created_host');
1248
1249 if ( $is_email_to_host ) {
1250 $new_event_email = new New_Event_Email( $booking );
1251 $new_event_email->send();
1252 }
1253
1254 if ( $is_email_to_customer ) {
1255 $new_event_customer_email = new New_Event_Customer_Email( $booking );
1256 $new_event_customer_email->send();
1257 }
1258
1259 do_action( 'timetics_gln_hook', 'booking_created', Notification::get_hook_data( $booking ) );
1260 }
1261
1262 // Fire after booking schedule create.
1263 do_action( 'timetics_after_booking_schedule', $booking->get_id(), $customer->get_id(), $meeting->get_id(), $data );
1264
1265 $data = [
1266 'success' => 1,
1267 'status_code' => 200,
1268 /* translators: Action */
1269 'message' => sprintf( esc_html__( 'Successfully %s booking', 'timetics' ), $action ),
1270 'data' => $this->prepare_item( $booking ),
1271 ];
1272
1273 return new WP_HTTP_Response( $data, 200 );
1274 }
1275
1276 /**
1277 * Prepare item for response
1278 *
1279 * @param integer $booking_id
1280 *
1281 * @return array
1282 */
1283 public function prepare_item( $booking_id, $expose_token = true ) {
1284 $booking = new Booking( $booking_id );
1285 $appointment = new Appointment( $booking->get_appointment() );
1286 $staff = new Staff( $booking->get_staff_id() );
1287 $customer = new Customer( $booking->get_customer_id() );
1288 $meeting_timezone = $appointment->get_timezone();
1289 $booking_timezone = $booking->get_timezone();
1290
1291 $start_date_time = timetics_convert_timezone( $booking->get_start_date() . ' ' . $booking->get_start_time(), $booking_timezone, $meeting_timezone );
1292 $end_date_time = timetics_convert_timezone( $booking->get_end_date() . ' ' . $booking->get_end_time(), $booking_timezone, $meeting_timezone );
1293 $date = timetics_datetime( 'Y-m-d', $booking->get_date(), $meeting_timezone );
1294
1295 $event = $booking->get_event();
1296 $join_link = 'google-meet' === $booking->get_location_type() && ! empty( $event['hangoutLink'] ) ? $event['hangoutLink'] : '';
1297
1298 $booking_title = $appointment->is_appointment() ? $appointment->get_name() : $booking->get_appointment_name();
1299
1300 $payment_details_raw = $booking->get_payment_details();
1301 $payment_details = is_array( $payment_details_raw ) ? $payment_details_raw : [];
1302
1303 $response = [
1304 'id' => $booking->get_id(),
1305 'random_id' => $booking->get_random_id(),
1306 'status' => $booking->get_status(),
1307 'order_total' => $booking->get_total(),
1308 'start_date' => $start_date_time->format( 'Y-m-d' ),
1309 'end_date' => $end_date_time->format( 'Y-m-d' ),
1310 'date' => $date,
1311 'start_time' => $start_date_time->format( 'h:i a' ),
1312 'end_time' => $end_date_time->format( 'h:i a' ),
1313 'booking_time' => $booking->get_booking_time(),
1314 'location' => $booking->get_location(),
1315 'location_type' => $booking->get_location_type(),
1316 'description' => $booking->get_description(),
1317 'cancel_reason' => $booking->get_cancel_reason(),
1318 // Listing endpoints (get_items / get_booking_list) pass $expose_token = false —
1319 // a viewer browsing many bookings at once has no legitimate need for every
1320 // one's bearer token; single-booking reads (create/get/update) keep it.
1321 'security_token' => $expose_token ? $booking->get_security_token() : '',
1322 'payment_method' => $booking->get_payment_method(),
1323 'payment_status' => $booking->get_payment_status(),
1324 'payment_details' => $payment_details,
1325 'customer' => [
1326 'id' => $customer->get_id(),
1327 'full_name' => $customer->get_display_name(),
1328 'first_name' => $customer->get_first_name(),
1329 'last_name' => $customer->get_last_name(),
1330 'email' => $customer->get_email(),
1331 'phone' => $customer->get_phone(),
1332 ],
1333 'appointment' => [
1334 'id' => $appointment->get_id(),
1335 'name' => $booking_title,
1336 'duration' => $appointment->get_duration(),
1337 'type' => $appointment->get_type(),
1338 'price' => $appointment->get_price(),
1339 'locations' => $appointment->get_locations(),
1340 'timezone' => $appointment->get_timezone(),
1341 'permalink' => $appointment->get_appointment_permalink(),
1342 ],
1343 'staff' => [
1344 'id' => $staff->get_id(),
1345 'full_name' => $staff->get_display_name(),
1346 'first_name' => $staff->get_first_name(),
1347 'last_name' => $staff->get_last_name(),
1348 'email_name' => $staff->get_email(),
1349 'phone' => $staff->get_phone(),
1350 'image' => $staff->get_image(),
1351 ],
1352 ];
1353
1354 if ( $join_link ) {
1355 $response['meeting_link'] = $join_link;
1356 }
1357
1358 return apply_filters( 'timetics_booking_json_data', $response, $booking );
1359 }
1360
1361 /**
1362 * Delete booking
1363 *
1364 * @param integer $booking_id
1365 *
1366 * @return bool
1367 */
1368 private function delete( $booking_id ) {
1369 $booking = new Booking( $booking_id );
1370 $meeting = new Appointment( $booking->get_appointment() );
1371
1372 if ( ! $booking->is_booking() ) {
1373 return false;
1374 }
1375
1376 $current_user_id = get_current_user_id();
1377
1378 if (
1379 $meeting->is_appointment()
1380 && ! user_can( $current_user_id, 'manage_options' )
1381 && $meeting->get_author() != $current_user_id
1382 ) {
1383 $data = [
1384 'success' => 0,
1385 'message' => __( 'You are not allowed to delete this booking.', 'timetics' ),
1386 ];
1387
1388 return new WP_HTTP_Response( $data, 403 );
1389 }
1390
1391
1392 $booking->release_slot();
1393
1394 $recurrences = $booking->get_recurrence();
1395 $booking->delete_event();
1396 $booking->delete();
1397
1398 $is_email_to_customer = timetics_get_option( 'booking_canceled_customer');
1399 $is_email_to_host = timetics_get_option( 'booking_canceled_host');
1400
1401 if ( $is_email_to_host ) {
1402 $cancel_event_email = new Cancel_Event_Email( $booking );
1403 $cancel_event_email->send();
1404 }
1405
1406 if ( $is_email_to_customer ) {
1407
1408 $customer_cancel_event_email = new Cancel_Event_Customer_Email( $booking );
1409 $customer_cancel_event_email->send();
1410 }
1411
1412 do_action( 'timetics_gln_hook', 'booking_canceled', Notification::get_hook_data( $booking ) );
1413
1414
1415
1416 do_action( 'timetics_after_booking_delete', $recurrences );
1417
1418 return true;
1419 }
1420
1421 public function is_available_slot( $meeting, $booking_data = [] ) {
1422 $start_date = $booking_data['start_date'];
1423 $start_time = $booking_data['start_time'];
1424 $booking_timezone = $booking_data['timezone'];
1425 $booking_entry = new Booking_Entry();
1426 $meeting_id = $meeting->get_id();
1427 $staff_id = $booking_data['staff_id'];
1428
1429 $booking_entries = new Booking_Entry();
1430 $meeting = new Appointment( $meeting_id );
1431 $slot_datetime = timetics_convert_timezone( $start_date . ' ' . $start_time, $booking_timezone, $meeting->get_timezone() );
1432
1433 $entries = $booking_entries->find( [
1434 'meeting_id' => $meeting_id,
1435 'staff_id' => $staff_id,
1436 'date' => $slot_datetime->format( 'Y-m-d' ),
1437 'start' => $slot_datetime->format( 'h:i a' ),
1438 ] );
1439
1440 $booked = $entries ? $booking_entries->first() : false;
1441
1442 if ( $booked && intval( $booked->get_booked() ) >= $meeting->get_effective_capacity() ) {
1443 return false;
1444 }
1445
1446 /**
1447 * Let integrations veto a slot at booking time.
1448 *
1449 * Slot listing is filtered separately, so without this a client posting
1450 * straight to the REST endpoint could still book a slot that the UI
1451 * hides — which is how a Google Calendar conflict turned into a real
1452 * double booking. Integrations must fail open: return true when they
1453 * cannot determine availability.
1454 *
1455 * @param bool $available
1456 * @param Appointment $meeting
1457 * @param array $booking_data
1458 */
1459 return (bool) apply_filters( 'timetics_is_slot_available', true, $meeting, $booking_data );
1460 }
1461
1462 /**
1463 * Resolve what `timetics_booking_update_schedule` returned into an update payload.
1464 *
1465 * The filter passes the entry as its filtered value and the payload only as
1466 * an extra argument, so with nothing hooked it hands back the entry object.
1467 * Booking_Entry::update() then matches none of its keys and silently writes
1468 * nothing, leaving group counters frozen. Keep the published signature and
1469 * fall back to the payload whenever the result is not usable.
1470 *
1471 * @param mixed $filtered Whatever the filter returned.
1472 * @param integer $booked Counter this call meant to store.
1473 *
1474 * @return array
1475 */
1476 private function normalize_schedule_update( $filtered, $booked ) {
1477 return is_array( $filtered ) ? $filtered : [ 'booked' => $booked ];
1478 }
1479
1480 /**
1481 * Validates a booking.
1482 *
1483 * @param int $appointment_id The ID of the appointment.
1484 * @param array $data The data for the booking.
1485 * @throws None
1486 * @return mixed Returns an error response if the validation fails, otherwise returns nothing.
1487 */
1488 public function validate_booking($appointment_id, $data) {
1489 $meeting = new Appointment($appointment_id);
1490 $all_seats = (array) $meeting->get_seats();
1491 $meeting_price = $meeting->get_price();
1492 $meeting_locations = (array) $meeting->get_locations();
1493 $total_price = 0;
1494
1495 $staff_id = ! empty( $data['staff'] ) ? intval( $data['staff'] ) : 0;
1496 $order_total = ! empty( $data['order_total'] ) ? floatval( $data['order_total'] ) : 0;
1497 $location_type = ! empty( $data['location_type'] ) ? sanitize_text_field( $data['location_type'] ) : '';
1498 $start_date = ! empty( $data['start_date'] ) ? sanitize_text_field( $data['start_date'] ) : '';
1499 $timezone = ! empty( $data['timezone'] ) ? sanitize_text_field( $data['timezone'] ) : '';
1500 $start_time = ! empty( $data['start_time'] ) ? sanitize_text_field( $data['start_time'] ) : '';
1501 $status = ! empty( $data['status'] ) ? sanitize_text_field( $data['status'] ) : '';
1502 $seats = ! empty( $data['seats'] ) ? $data['seats'] : [];
1503 $timeslots = $meeting->get_avilable_timeslots( $start_date, $staff_id, $timezone );
1504 $meeting_has_buffer_time = $meeting->get_buffer_time_after_in_seconds() > 0 || $meeting->get_buffer_time_before_in_seconds() > 0;
1505
1506 if ( ! $meeting->is_appointment() ) {
1507 return $this->create_error_response( __( 'Invalid meeting.', 'timetics' ), 422 );
1508 }
1509
1510 if ( 'cancel' !== $status ) {
1511 if ( ! $meeting_has_buffer_time && ! in_array( gmdate( 'g:ia', strtotime( $start_time ) ), $timeslots ) ) {
1512 return $this->create_error_response( __( 'Invalid timeslot.', 'timetics' ), 422 );
1513 }
1514
1515 // Check if the staff is matched
1516 if ( ! in_array( $staff_id, $meeting->get_staff_ids() ) ) {
1517 return $this->create_error_response(__('Team member not matched', 'timetics'), 403);
1518
1519 }
1520 // Check if the location type is matched
1521 if ( ! in_array( $location_type, array_column( $meeting_locations, 'location_type' ) ) ) {
1522 return $this->create_error_response(__('Location type not matched', 'timetics'), 403);
1523 }
1524 }
1525 }
1526
1527 /**
1528 * Creates an error response with the given message and status code.
1529 *
1530 * @param string $message The error message.
1531 * @param int $status_code The HTTP status code.
1532 * @return WP_HTTP_Response The error response.
1533 */
1534 public function create_error_response($message, $status_code) {
1535 return new WP_Error( 'timezone_error', $message, ['status' => $status_code] );
1536 }
1537
1538 /**
1539 * Calculate order total
1540 *
1541 * @param array $data Request data
1542 *
1543 * @return integer
1544 */
1545 private function calculate_order_total($data) {
1546 $seats = ! empty( $data['seats'] ) ? $data['seats'] : [];
1547 $meeting_id = ! empty( $data['appointment'] ) ? $data['appointment'] : 0;
1548 $total_price = 0;
1549
1550 if ( class_exists( SeatPlan::class ) && $seats ) {
1551 foreach( $seats as $seat ) {
1552 $seat_object = SeatPlan::find( $seat );
1553 $total_price += $seat_object->price;
1554 }
1555
1556 return $total_price;
1557 }
1558
1559 $meeting = new Appointment( $meeting_id );
1560
1561 $prices = $meeting->get_price();
1562
1563 if ( $prices && is_array( $prices ) ) {
1564 return $prices[0]['ticket_price'];
1565 }
1566
1567 return 0;
1568 }
1569
1570 /**
1571 * Update item permission callback
1572 * @param WP_REST_Request $request
1573 * @return bool
1574 */
1575 public function update_item_permission_callback($request){
1576 $nonce = $request->get_header('X-WP-Nonce');
1577
1578 $booking_id = (int) $request->get_param('booking_id');
1579 $appointment_token = $request->get_param('appointment_token');
1580
1581 $booking = new Booking($booking_id);
1582
1583 if (!$booking->is_booking()) {
1584 return false;
1585 }
1586
1587 // Guests: must provide a valid token (constant-time compare).
1588 if ( ! empty( $appointment_token ) ) {
1589 $stored_token = (string) $booking->get_security_token();
1590 if ( '' !== $stored_token && hash_equals( $stored_token, (string) $appointment_token ) ) {
1591 return true;
1592 }
1593 }
1594
1595 if (empty($booking_id) || ! wp_verify_nonce($nonce, 'wp_rest')) {
1596 return false;
1597 }
1598
1599 // manage_timetics is not admin-only — every staff account holds it — so it
1600 // cannot stand in for an ownership check. Real admins, the booking's own
1601 // customer, or staff this specific booking is actually visible to.
1602 if (
1603 ( get_current_user_id() > 0 && (int) $booking->get_customer_id() === get_current_user_id() )
1604 || timetics_can_view_all_data()
1605 || in_array( $booking_id, timetics_get_visible_booking_ids(), true )
1606 ) {
1607 return true;
1608 }
1609
1610 return false;
1611 }
1612
1613 /**
1614 * Get item permission callback
1615 * @param WP_Rest_Request $request
1616 * @return bool
1617 */
1618 public function get_item_permission_callback($request){
1619 $nonce = $request->get_header('X-WP-Nonce');
1620 $booking_id = (int) $request->get_param('booking_id');
1621 $appointment_token = $request->get_param('appointment_token');
1622
1623 $booking = new Booking($booking_id);
1624
1625 if (!$booking->is_booking()) {
1626 return false;
1627 }
1628
1629 // Guests: must provide a valid token (constant-time compare).
1630 if ( ! empty( $appointment_token ) ) {
1631 $stored_token = (string) $booking->get_security_token();
1632 if ( '' !== $stored_token && hash_equals( $stored_token, (string) $appointment_token ) ) {
1633 return true;
1634 }
1635 }
1636
1637 if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
1638 return false;
1639 }
1640
1641 if (
1642 ( get_current_user_id() > 0 && (int) $booking->get_customer_id() === get_current_user_id() )
1643 || timetics_can_view_all_data()
1644 || in_array( $booking_id, timetics_get_visible_booking_ids(), true )
1645 ) {
1646 return true;
1647 }
1648
1649 return false;
1650 }
1651
1652 /**
1653 * Validate email change permission during booking update.
1654 *
1655 * Prevents non-admin users from reassigning bookings to other users
1656 * by changing the email address. Follows the principle of least privilege.
1657 *
1658 * @param int $booking_id The ID of the booking being updated.
1659 * @param string $new_email The new email address from the request.
1660 *
1661 * @return string|WP_Error Returns the validated email on success, WP_Error on failure.
1662 */
1663 private function validate_email_change_permission( $booking_id, $new_email ) {
1664 // manage_timetics is not admin-only — every staff account holds it.
1665 if ( timetics_can_view_all_data() ) {
1666 return $new_email;
1667 }
1668
1669 $existing_booking = new Booking( $booking_id );
1670
1671 if ( ! $existing_booking->is_booking() ) {
1672 return new WP_Error( 404, __( 'Booking not found.', 'timetics' ) );
1673 }
1674
1675 // Get original customer email
1676 $existing_customer = new Customer( $existing_booking->get_customer_id() );
1677 $original_email = $existing_customer->get_email();
1678
1679 if ( empty( $original_email ) ) {
1680 return new WP_Error( 500, __( 'Unable to verify booking ownership.', 'timetics' ) );
1681 }
1682
1683 // Check if email is being changed (case-insensitive comparison)
1684 $is_email_changed = ! empty( $new_email ) && strtolower( trim( $new_email ) ) !== strtolower( trim( $original_email ) );
1685
1686 if ( $is_email_changed ) {
1687 return new WP_Error( 403, __( 'You are not allowed to change the email address for this booking.', 'timetics' ) );
1688 }
1689
1690 return $original_email;
1691 }
1692
1693 /**
1694 * Bind a Stripe PaymentIntent to a booking by writing the booking_id and security_token into the PaymentIntent's metadata.
1695 *
1696 * @param \WP_REST_Request $request
1697 * @return \WP_HTTP_Response
1698 */
1699 public function bind_payment_intent( $request ) {
1700 $booking_id = (int) $request['booking_id'];
1701 $booking = new Booking( $booking_id );
1702
1703 if ( ! $booking->is_booking() ) {
1704 return new WP_HTTP_Response(
1705 [
1706 'success' => 0,
1707 'status_code' => 404,
1708 'message' => esc_html__( 'Invalid booking id.', 'timetics' ),
1709 ],
1710 404
1711 );
1712 }
1713
1714 $body = json_decode( $request->get_body(), true );
1715 $body = is_array( $body ) ? $body : [];
1716 $intent_id = ! empty( $body['payment_intent_id'] ) ? sanitize_text_field( (string) $body['payment_intent_id'] ) : '';
1717
1718 if ( '' === $intent_id || strpos( $intent_id, 'pi_' ) !== 0 ) {
1719 return new WP_HTTP_Response(
1720 [
1721 'success' => 0,
1722 'status_code' => 400,
1723 'message' => esc_html__( 'Invalid payment intent id.', 'timetics' ),
1724 ],
1725 400
1726 );
1727 }
1728
1729 $stripe = new StripePayment();
1730
1731 $bound = $booking->get_stripe_payment_intent_id();
1732 if ( '' !== $bound && $bound !== $intent_id ) {
1733 return new WP_HTTP_Response(
1734 [
1735 'success' => 0,
1736 'status_code' => 409,
1737 'message' => esc_html__( 'Booking already bound to another payment intent.', 'timetics' ),
1738 ],
1739 409
1740 );
1741 }
1742
1743 $intent = $stripe->retrieve_payment_intent( $intent_id );
1744
1745 if ( is_wp_error( $intent ) || ! is_array( $intent ) || empty( $intent['id'] ) ) {
1746 return new WP_HTTP_Response(
1747 [
1748 'success' => 0,
1749 'status_code' => 502,
1750 'message' => esc_html__( 'Cannot verify payment intent with Stripe.', 'timetics' ),
1751 ],
1752 502
1753 );
1754 }
1755
1756 $expected_amount = (int) round( (float) $booking->get_total() * 100 );
1757 $expected_currency = strtolower( (string) apply_filters( 'timetics_currency', timetics_get_option( 'currency', 'USD' ) ) );
1758 $intent_amount = isset( $intent['amount'] ) ? (int) $intent['amount'] : 0;
1759 $intent_currency = isset( $intent['currency'] ) ? strtolower( (string) $intent['currency'] ) : '';
1760 $intent_meta_book = isset( $intent['metadata']['booking_id'] ) ? (int) $intent['metadata']['booking_id'] : 0;
1761
1762 if ( $expected_amount <= 0 || $intent_amount !== $expected_amount || $intent_currency !== $expected_currency ) {
1763 return new WP_HTTP_Response(
1764 [
1765 'success' => 0,
1766 'status_code' => 409,
1767 'message' => esc_html__( 'Payment intent does not match this booking.', 'timetics' ),
1768 ],
1769 409
1770 );
1771 }
1772
1773 if ( 0 !== $intent_meta_book && $booking_id !== $intent_meta_book ) {
1774 return new WP_HTTP_Response(
1775 [
1776 'success' => 0,
1777 'status_code' => 409,
1778 'message' => esc_html__( 'Payment intent is bound to another booking.', 'timetics' ),
1779 ],
1780 409
1781 );
1782 }
1783
1784 $result = $stripe->update_payment_intent(
1785 $intent_id,
1786 [
1787 'booking_id' => $booking_id,
1788 'security_token' => (string) $booking->get_security_token(),
1789 ]
1790 );
1791
1792 if ( is_wp_error( $result ) ) {
1793 return new WP_HTTP_Response(
1794 [
1795 'success' => 0,
1796 'status_code' => 502,
1797 'message' => $result->get_error_message(),
1798 ],
1799 502
1800 );
1801 }
1802
1803 return new WP_HTTP_Response(
1804 [
1805 'success' => 1,
1806 'status_code' => 200,
1807 'message' => esc_html__( 'Payment intent bound.', 'timetics' ),
1808 ],
1809 200
1810 );
1811 }
1812
1813 public function make_payment_permission_callback( $request ) {
1814
1815 $booking_id = (int) $request->get_param('booking_id');
1816 $appointment_token = sanitize_text_field( $request->get_param('appointment_token') );
1817
1818 if ( empty( $booking_id ) || empty( $appointment_token ) ) {
1819 return false;
1820 }
1821
1822 $booking = new Booking( $booking_id );
1823
1824 if ( ! $booking->is_booking() ) {
1825 return false;
1826 }
1827
1828 $stored_token = $booking->get_security_token();
1829
1830 if ( empty( $stored_token ) ) {
1831 return false;
1832 }
1833
1834 // constant-time comparison
1835 if ( ! hash_equals( $stored_token, $appointment_token ) ) {
1836 return false;
1837 }
1838 if ( 'pending' !== (string) $booking->get_status() ) {
1839 return false;
1840 }
1841
1842 return true;
1843 }
1844
1845 }
1846