PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / 1.0.60
Timetics – Appointment Booking Calendar & Scheduling v1.0.60
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 / bookings / api-booking.php

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

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