PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / 1.0.58
Timetics – Appointment Booking Calendar & Scheduling v1.0.58
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 1.0.23 1.0.24 All 62 releases
timetics / core / bookings / api-booking.php

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

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