PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / 1.0.57
Timetics – Appointment Booking Calendar & Scheduling v1.0.57
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.57, at core/bookings/api-booking.php

1,735 lines 64.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\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 // Create or update calendar event.
1071 if ( $id ) {
1072 if ( 'cancel' === $status ) {
1073 $booking->delete_event();
1074 $is_email_to_customer = timetics_get_option( 'booking_canceled_customer');
1075 $is_email_to_host = timetics_get_option( 'booking_canceled_host');
1076
1077 if ( $is_email_to_host ) {
1078 $cancel_event_email = new Cancel_Event_Email( $booking );
1079 $cancel_event_email->send();
1080 }
1081
1082 if ( $is_email_to_customer ) {
1083 $customer_cancel_event_email = new Cancel_Event_Customer_Email( $booking );
1084 $customer_cancel_event_email->send();
1085 }
1086
1087 /**
1088 * Added temporary for leagacy sass. It will remove in future.
1089 */
1090 do_action( 'timetics/admin/booking/after_delete_item', $booking );
1091 } else {
1092 // Check if the booking date/time was actually changed
1093 $date_time_changed = (
1094 $old_start_date !== $start_date ||
1095 $old_start_time !== $start_time ||
1096 $old_end_time !== $end_time
1097 );
1098
1099 $booking->update_event();
1100
1101 if ( $date_time_changed ) {
1102 $is_email_to_reschedule_customer = timetics_get_option( 'booking_rescheduled_customer');
1103 $is_email_to_reschedule_host = timetics_get_option( 'booking_rescheduled_host');
1104
1105 if ( $is_email_to_reschedule_host ) {
1106 $update_event_email = new Update_Event_Email( $booking );
1107 $update_event_email->send();
1108 }
1109
1110 if ( $is_email_to_reschedule_customer ) {
1111 $update_event_customer_email = new Update_Event_Customer_Email( $booking );
1112 $update_event_customer_email->send();
1113 }
1114 }
1115 }
1116 }
1117
1118 // Convert booking time to staff/meeting time.
1119 $date_time = timetics_convert_timezone( $start_date . ' ' . $start_time, $timezone, $meeting->get_timezone() );
1120 $end_time = timetics_convert_timezone( $start_date . ' ' . $end_time, $timezone, $meeting->get_timezone() );
1121
1122 // Create booking schedule.
1123 $entries = $booking_entry->find(
1124 [
1125 'staff_id' => $staff->get_id(),
1126 'meeting_id' => $meeting->get_id(),
1127 'date' => $date_time->format( 'Y-m-d' ),
1128 'start' => $date_time->format( 'h:i a' ),
1129 ]
1130 );
1131
1132 if ( $entries ) {
1133 $entry = $booking_entry->first();
1134
1135 if ( 'cancel' === $status ) {
1136 $booked = intval( $entry->get_booked() ) - 1;
1137 } else {
1138 $booked = intval( $entry->get_booked() ) + 1;
1139 }
1140
1141 $booked_data = apply_filters( 'timetics_booking_update_schedule', $entry, ['booked' => $booked], $data, $booking );
1142
1143 if ( 'cancel' === $status && 'one-to-one' == strtolower( $meeting->get_type() ) ) {
1144 $entry->delete();
1145 } else {
1146 $entry->update( $booked_data );
1147 }
1148
1149 } else {
1150 $book_entry_data = [
1151 'meeting_id' => $meeting->get_id(),
1152 'staff_id' => $staff->get_id(),
1153 'customer_id' => $customer->get_id(),
1154 'booking_id' => $booking->get_id(),
1155 'booked' => 1,
1156 'date' => $date_time->format( 'Y-m-d' ),
1157 'start' => $date_time->format( 'h:i a' ),
1158 'end' => $end_time->format( 'h:i a' ),
1159 ];
1160
1161 $book_entry_data = apply_filters( 'timetics_booking_schedule', $book_entry_data, $data );
1162 $booking_entry->create( $book_entry_data );
1163 }
1164
1165 // Fire after booking schedule create.
1166 do_action( 'timetics_after_booking_schedule', $booking->get_id(), $customer->get_id(), $meeting->get_id(), $data );
1167
1168 $data = [
1169 'success' => 1,
1170 'status_code' => 200,
1171 /* translators: Action */
1172 'message' => sprintf( esc_html__( 'Successfully %s booking', 'timetics' ), $action ),
1173 'data' => $this->prepare_item( $booking ),
1174 ];
1175
1176 return new WP_HTTP_Response( $data, 200 );
1177 }
1178
1179 /**
1180 * Prepare item for response
1181 *
1182 * @param integer $booking_id
1183 *
1184 * @return array
1185 */
1186 public function prepare_item( $booking_id ) {
1187 $booking = new Booking( $booking_id );
1188 $appointment = new Appointment( $booking->get_appointment() );
1189 $staff = new Staff( $booking->get_staff_id() );
1190 $customer = new Customer( $booking->get_customer_id() );
1191 $meeting_timezone = $appointment->get_timezone();
1192 $booking_timezone = $booking->get_timezone();
1193
1194 $start_date_time = timetics_convert_timezone( $booking->get_start_date() . ' ' . $booking->get_start_time(), $booking_timezone, $meeting_timezone );
1195 $end_date_time = timetics_convert_timezone( $booking->get_end_date() . ' ' . $booking->get_end_time(), $booking_timezone, $meeting_timezone );
1196 $date = timetics_datetime( 'Y-m-d', $booking->get_date(), $meeting_timezone );
1197
1198 $event = $booking->get_event();
1199 $join_link = 'google-meet' === $booking->get_location_type() && ! empty( $event['hangoutLink'] ) ? $event['hangoutLink'] : '';
1200
1201 $booking_title = $appointment->is_appointment() ? $appointment->get_name() : $booking->get_appointment_name();
1202
1203 $payment_details_raw = $booking->get_payment_details();
1204 $payment_details = is_array( $payment_details_raw ) ? $payment_details_raw : [];
1205
1206 $response = [
1207 'id' => $booking->get_id(),
1208 'random_id' => $booking->get_random_id(),
1209 'status' => $booking->get_status(),
1210 'order_total' => $booking->get_total(),
1211 'start_date' => $start_date_time->format( 'Y-m-d' ),
1212 'end_date' => $end_date_time->format( 'Y-m-d' ),
1213 'date' => $date,
1214 'start_time' => $start_date_time->format( 'h:i a' ),
1215 'end_time' => $end_date_time->format( 'h:i a' ),
1216 'booking_time' => $booking->get_booking_time(),
1217 'location' => $booking->get_location(),
1218 'location_type' => $booking->get_location_type(),
1219 'description' => $booking->get_description(),
1220 'cancel_reason' => $booking->get_cancel_reason(),
1221 'security_token' => $booking->get_security_token(),
1222 'payment_method' => $booking->get_payment_method(),
1223 'payment_status' => $booking->get_payment_status(),
1224 'payment_details' => $payment_details,
1225 'customer' => [
1226 'id' => $customer->get_id(),
1227 'full_name' => $customer->get_display_name(),
1228 'first_name' => $customer->get_first_name(),
1229 'last_name' => $customer->get_last_name(),
1230 'email' => $customer->get_email(),
1231 'phone' => $customer->get_phone(),
1232 ],
1233 'appointment' => [
1234 'id' => $appointment->get_id(),
1235 'name' => $booking_title,
1236 'duration' => $appointment->get_duration(),
1237 'type' => $appointment->get_type(),
1238 'price' => $appointment->get_price(),
1239 'locations' => $appointment->get_locations(),
1240 'timezone' => $appointment->get_timezone(),
1241 'permalink' => $appointment->get_appointment_permalink(),
1242 ],
1243 'staff' => [
1244 'id' => $staff->get_id(),
1245 'full_name' => $staff->get_display_name(),
1246 'first_name' => $staff->get_first_name(),
1247 'last_name' => $staff->get_last_name(),
1248 'email_name' => $staff->get_email(),
1249 'phone' => $staff->get_phone(),
1250 'image' => $staff->get_image(),
1251 ],
1252 ];
1253
1254 if ( $join_link ) {
1255 $response['meeting_link'] = $join_link;
1256 }
1257
1258 return apply_filters( 'timetics_booking_json_data', $response, $booking );
1259 }
1260
1261 /**
1262 * Delete booking
1263 *
1264 * @param integer $booking_id
1265 *
1266 * @return bool
1267 */
1268 private function delete( $booking_id ) {
1269 $booking = new Booking( $booking_id );
1270 $meeting = new Appointment( $booking->get_appointment() );
1271
1272 if ( ! $booking->is_booking() ) {
1273 return false;
1274 }
1275
1276 $current_user_id = get_current_user_id();
1277
1278 if (
1279 $meeting->is_appointment()
1280 && ! user_can( $current_user_id, 'manage_options' )
1281 && $meeting->get_author() != $current_user_id
1282 ) {
1283 $data = [
1284 'success' => 0,
1285 'message' => __( 'You are not allowed to delete this booking.', 'timetics' ),
1286 ];
1287
1288 return new WP_HTTP_Response( $data, 403 );
1289 }
1290
1291 $booking_entry = new Booking_Entry();
1292
1293 $date_time = timetics_convert_timezone( $booking->get_start_date() . ' ' . $booking->get_start_time(), $booking->get_timezone(), $meeting->get_timezone() );
1294
1295 $entries = $booking_entry->find(
1296 [
1297 'staff_id' => $booking->get_staff_id(),
1298 'meeting_id' => $booking->get_appointment(),
1299 'date' => $date_time->format( 'Y-m-d' ),
1300 'start' => $date_time->format( 'h:i a' ),
1301 ]
1302 );
1303
1304 if ( $entries ) {
1305 $entry = $booking_entry->first();
1306
1307 if ( 'one-to-one' == strtolower( $meeting->get_type() ) ) {
1308 $entry->delete();
1309 } else {
1310 $booked = intval( $entry->get_booked() ) - 1;
1311 $booked_seat = ! empty( $booking->get_seat() ) ? $booking->get_seat() : [];
1312 $existing_seat = ! empty( $entry->get_seats() ) ? $entry->get_seats() : [];
1313
1314 $entry->update( [
1315 'booked' => $booked,
1316 'seats' => array_values( array_diff( $existing_seat, $booked_seat ) ),
1317 ] );
1318 }
1319 }
1320
1321 $recurrences = $booking->get_recurrence();
1322 $booking->delete_event();
1323 $booking->delete();
1324
1325 $is_email_to_customer = timetics_get_option( 'booking_canceled_customer');
1326 $is_email_to_host = timetics_get_option( 'booking_canceled_host');
1327
1328 if ( $is_email_to_host ) {
1329 $cancel_event_email = new Cancel_Event_Email( $booking );
1330 $cancel_event_email->send();
1331 }
1332
1333 if ( $is_email_to_customer ) {
1334
1335 $customer_cancel_event_email = new Cancel_Event_Customer_Email( $booking );
1336 $customer_cancel_event_email->send();
1337 }
1338
1339
1340
1341 do_action( 'timetics_after_booking_delete', $recurrences );
1342
1343 return true;
1344 }
1345
1346 public function is_available_slot( $meeting, $booking_data = [] ) {
1347 $start_date = $booking_data['start_date'];
1348 $start_time = $booking_data['start_time'];
1349 $booking_timezone = $booking_data['timezone'];
1350 $booking_entry = new Booking_Entry();
1351 $meeting_id = $meeting->get_id();
1352 $staff_id = $booking_data['staff_id'];
1353
1354 $time = is_string( $start_time ) ? strtotime( $start_time ) : $start_time;
1355 $time = gmdate( 'H:i', $time );
1356 $booking_entries = new Booking_Entry();
1357 $meeting = new Appointment( $meeting_id );
1358
1359 $entries = $booking_entries->find( [
1360 'meeting_id' => $meeting_id,
1361 'staff_id' => $staff_id,
1362 'date' => $start_date,
1363 ] );
1364
1365 $booked = false;
1366
1367 foreach ( $entries as $entry ) {
1368 $booking = new Booking( $entry->get_booking_id() );
1369 $booking_time = timetics_convert_timezone( $booking->get_start_date() . ' ' . $entry->get_start(), $booking->get_timezone(), $booking_timezone )->format( 'H:i' );
1370
1371 if ( $booking_time == $time ) {
1372 $booked = $entry;
1373 break;
1374 }
1375 }
1376
1377 if ( $booked && $booked->get_booked() >= $meeting->get_capacity() ) {
1378 return false;
1379 }
1380
1381 return true;
1382 }
1383
1384 /**
1385 * Validates a booking.
1386 *
1387 * @param int $appointment_id The ID of the appointment.
1388 * @param array $data The data for the booking.
1389 * @throws None
1390 * @return mixed Returns an error response if the validation fails, otherwise returns nothing.
1391 */
1392 public function validate_booking($appointment_id, $data) {
1393 $meeting = new Appointment($appointment_id);
1394 $all_seats = (array) $meeting->get_seats();
1395 $meeting_price = $meeting->get_price();
1396 $meeting_locations = (array) $meeting->get_locations();
1397 $total_price = 0;
1398
1399 $staff_id = ! empty( $data['staff'] ) ? intval( $data['staff'] ) : 0;
1400 $order_total = ! empty( $data['order_total'] ) ? floatval( $data['order_total'] ) : 0;
1401 $location_type = ! empty( $data['location_type'] ) ? sanitize_text_field( $data['location_type'] ) : '';
1402 $start_date = ! empty( $data['start_date'] ) ? sanitize_text_field( $data['start_date'] ) : '';
1403 $timezone = ! empty( $data['timezone'] ) ? sanitize_text_field( $data['timezone'] ) : '';
1404 $start_time = ! empty( $data['start_time'] ) ? sanitize_text_field( $data['start_time'] ) : '';
1405 $status = ! empty( $data['status'] ) ? sanitize_text_field( $data['status'] ) : '';
1406 $seats = ! empty( $data['seats'] ) ? $data['seats'] : [];
1407 $timeslots = $meeting->get_avilable_timeslots( $start_date, $staff_id, $timezone );
1408 $meeting_has_buffer_time = $meeting->get_buffer_time_after_in_seconds() > 0 || $meeting->get_buffer_time_before_in_seconds() > 0;
1409
1410 if ( ! $meeting->is_appointment() ) {
1411 return $this->create_error_response( __( 'Invalid meeting.', 'timetics' ), 422 );
1412 }
1413
1414 if ( 'cancel' !== $status ) {
1415 if ( ! $meeting_has_buffer_time && ! in_array( gmdate( 'g:ia', strtotime( $start_time ) ), $timeslots ) ) {
1416 return $this->create_error_response( __( 'Invalid timeslot.', 'timetics' ), 422 );
1417 }
1418
1419 // Check if the staff is matched
1420 if ( ! in_array( $staff_id, $meeting->get_staff_ids() ) ) {
1421 return $this->create_error_response(__('Team member not matched', 'timetics'), 403);
1422
1423 }
1424 // Check if the location type is matched
1425 if ( ! in_array( $location_type, array_column( $meeting_locations, 'location_type' ) ) ) {
1426 return $this->create_error_response(__('Location type not matched', 'timetics'), 403);
1427 }
1428 }
1429 }
1430
1431 /**
1432 * Creates an error response with the given message and status code.
1433 *
1434 * @param string $message The error message.
1435 * @param int $status_code The HTTP status code.
1436 * @return WP_HTTP_Response The error response.
1437 */
1438 public function create_error_response($message, $status_code) {
1439 return new WP_Error( 'timezone_error', $message, ['status' => $status_code] );
1440 }
1441
1442 /**
1443 * Calculate order total
1444 *
1445 * @param array $data Request data
1446 *
1447 * @return integer
1448 */
1449 private function calculate_order_total($data) {
1450 $seats = ! empty( $data['seats'] ) ? $data['seats'] : [];
1451 $meeting_id = ! empty( $data['appointment'] ) ? $data['appointment'] : 0;
1452 $total_price = 0;
1453
1454 if ( class_exists( SeatPlan::class ) && $seats ) {
1455 foreach( $seats as $seat ) {
1456 $seat_object = SeatPlan::find( $seat );
1457 $total_price += $seat_object->price;
1458 }
1459
1460 return $total_price;
1461 }
1462
1463 $meeting = new Appointment( $meeting_id );
1464
1465 $prices = $meeting->get_price();
1466
1467 if ( $prices && is_array( $prices ) ) {
1468 return $prices[0]['ticket_price'];
1469 }
1470
1471 return 0;
1472 }
1473
1474 /**
1475 * Update item permission callback
1476 * @param WP_REST_Request $request
1477 * @return bool
1478 */
1479 public function update_item_permission_callback($request){
1480 $nonce = $request->get_header('X-WP-Nonce');
1481
1482 $booking_id = (int) $request->get_param('booking_id');
1483 $appointment_token = $request->get_param('appointment_token');
1484
1485 $booking = new Booking($booking_id);
1486
1487 if (!$booking->is_booking()) {
1488 return false;
1489 }
1490
1491 // Guests: must provide a valid token (constant-time compare).
1492 if ( ! empty( $appointment_token ) ) {
1493 $stored_token = (string) $booking->get_security_token();
1494 if ( '' !== $stored_token && hash_equals( $stored_token, (string) $appointment_token ) ) {
1495 return true;
1496 }
1497 }
1498
1499 if (empty($booking_id) || ! wp_verify_nonce($nonce, 'wp_rest')) {
1500 return false;
1501 }
1502
1503 // Allow booking owner or admins/managers.
1504 if ( (int) $booking->get_customer_id() === get_current_user_id() || current_user_can( 'manage_timetics' )) {
1505 return true;
1506 }
1507
1508 return false;
1509 }
1510
1511 /**
1512 * Get item permission callback
1513 * @param WP_Rest_Request $request
1514 * @return bool
1515 */
1516 public function get_item_permission_callback($request){
1517 $nonce = $request->get_header('X-WP-Nonce');
1518 $booking_id = (int) $request->get_param('booking_id');
1519 $appointment_token = $request->get_param('appointment_token');
1520
1521 $booking = new Booking($booking_id);
1522
1523 if (!$booking->is_booking()) {
1524 return false;
1525 }
1526
1527 // Guests: must provide a valid token (constant-time compare).
1528 if ( ! empty( $appointment_token ) ) {
1529 $stored_token = (string) $booking->get_security_token();
1530 if ( '' !== $stored_token && hash_equals( $stored_token, (string) $appointment_token ) ) {
1531 return true;
1532 }
1533 }
1534
1535 if (wp_verify_nonce($nonce, 'wp_rest') && current_user_can( 'manage_timetics' ) ) {
1536 return true;
1537 }
1538 return false;
1539 }
1540
1541 /**
1542 * Validate email change permission during booking update.
1543 *
1544 * Prevents non-admin users from reassigning bookings to other users
1545 * by changing the email address. Follows the principle of least privilege.
1546 *
1547 * @param int $booking_id The ID of the booking being updated.
1548 * @param string $new_email The new email address from the request.
1549 *
1550 * @return string|WP_Error Returns the validated email on success, WP_Error on failure.
1551 */
1552 private function validate_email_change_permission( $booking_id, $new_email ) {
1553 // Admin users have full permission to change email addresses
1554 if ( current_user_can( 'manage_timetics' ) ) {
1555 return $new_email;
1556 }
1557
1558 $existing_booking = new Booking( $booking_id );
1559
1560 if ( ! $existing_booking->is_booking() ) {
1561 return new WP_Error( 404, __( 'Booking not found.', 'timetics' ) );
1562 }
1563
1564 // Get original customer email
1565 $existing_customer = new Customer( $existing_booking->get_customer_id() );
1566 $original_email = $existing_customer->get_email();
1567
1568 if ( empty( $original_email ) ) {
1569 return new WP_Error( 500, __( 'Unable to verify booking ownership.', 'timetics' ) );
1570 }
1571
1572 // Check if email is being changed (case-insensitive comparison)
1573 $is_email_changed = ! empty( $new_email ) && strtolower( trim( $new_email ) ) !== strtolower( trim( $original_email ) );
1574
1575 if ( $is_email_changed ) {
1576 return new WP_Error( 403, __( 'You are not allowed to change the email address for this booking.', 'timetics' ) );
1577 }
1578
1579 return $original_email;
1580 }
1581
1582 /**
1583 * Bind a Stripe PaymentIntent to a booking by writing the booking_id and security_token into the PaymentIntent's metadata.
1584 *
1585 * @param \WP_REST_Request $request
1586 * @return \WP_HTTP_Response
1587 */
1588 public function bind_payment_intent( $request ) {
1589 $booking_id = (int) $request['booking_id'];
1590 $booking = new Booking( $booking_id );
1591
1592 if ( ! $booking->is_booking() ) {
1593 return new WP_HTTP_Response(
1594 [
1595 'success' => 0,
1596 'status_code' => 404,
1597 'message' => esc_html__( 'Invalid booking id.', 'timetics' ),
1598 ],
1599 404
1600 );
1601 }
1602
1603 $body = json_decode( $request->get_body(), true );
1604 $body = is_array( $body ) ? $body : [];
1605 $intent_id = ! empty( $body['payment_intent_id'] ) ? sanitize_text_field( (string) $body['payment_intent_id'] ) : '';
1606
1607 if ( '' === $intent_id || strpos( $intent_id, 'pi_' ) !== 0 ) {
1608 return new WP_HTTP_Response(
1609 [
1610 'success' => 0,
1611 'status_code' => 400,
1612 'message' => esc_html__( 'Invalid payment intent id.', 'timetics' ),
1613 ],
1614 400
1615 );
1616 }
1617
1618 $stripe = new StripePayment();
1619
1620 $bound = $booking->get_stripe_payment_intent_id();
1621 if ( '' !== $bound && $bound !== $intent_id ) {
1622 return new WP_HTTP_Response(
1623 [
1624 'success' => 0,
1625 'status_code' => 409,
1626 'message' => esc_html__( 'Booking already bound to another payment intent.', 'timetics' ),
1627 ],
1628 409
1629 );
1630 }
1631
1632 $intent = $stripe->retrieve_payment_intent( $intent_id );
1633
1634 if ( is_wp_error( $intent ) || ! is_array( $intent ) || empty( $intent['id'] ) ) {
1635 return new WP_HTTP_Response(
1636 [
1637 'success' => 0,
1638 'status_code' => 502,
1639 'message' => esc_html__( 'Cannot verify payment intent with Stripe.', 'timetics' ),
1640 ],
1641 502
1642 );
1643 }
1644
1645 $expected_amount = (int) round( (float) $booking->get_total() * 100 );
1646 $expected_currency = strtolower( (string) apply_filters( 'timetics_currency', timetics_get_option( 'currency', 'USD' ) ) );
1647 $intent_amount = isset( $intent['amount'] ) ? (int) $intent['amount'] : 0;
1648 $intent_currency = isset( $intent['currency'] ) ? strtolower( (string) $intent['currency'] ) : '';
1649 $intent_meta_book = isset( $intent['metadata']['booking_id'] ) ? (int) $intent['metadata']['booking_id'] : 0;
1650
1651 if ( $expected_amount <= 0 || $intent_amount !== $expected_amount || $intent_currency !== $expected_currency ) {
1652 return new WP_HTTP_Response(
1653 [
1654 'success' => 0,
1655 'status_code' => 409,
1656 'message' => esc_html__( 'Payment intent does not match this booking.', 'timetics' ),
1657 ],
1658 409
1659 );
1660 }
1661
1662 if ( 0 !== $intent_meta_book && $booking_id !== $intent_meta_book ) {
1663 return new WP_HTTP_Response(
1664 [
1665 'success' => 0,
1666 'status_code' => 409,
1667 'message' => esc_html__( 'Payment intent is bound to another booking.', 'timetics' ),
1668 ],
1669 409
1670 );
1671 }
1672
1673 $result = $stripe->update_payment_intent(
1674 $intent_id,
1675 [
1676 'booking_id' => $booking_id,
1677 'security_token' => (string) $booking->get_security_token(),
1678 ]
1679 );
1680
1681 if ( is_wp_error( $result ) ) {
1682 return new WP_HTTP_Response(
1683 [
1684 'success' => 0,
1685 'status_code' => 502,
1686 'message' => $result->get_error_message(),
1687 ],
1688 502
1689 );
1690 }
1691
1692 return new WP_HTTP_Response(
1693 [
1694 'success' => 1,
1695 'status_code' => 200,
1696 'message' => esc_html__( 'Payment intent bound.', 'timetics' ),
1697 ],
1698 200
1699 );
1700 }
1701
1702 public function make_payment_permission_callback( $request ) {
1703
1704 $booking_id = (int) $request->get_param('booking_id');
1705 $appointment_token = sanitize_text_field( $request->get_param('appointment_token') );
1706
1707 if ( empty( $booking_id ) || empty( $appointment_token ) ) {
1708 return false;
1709 }
1710
1711 $booking = new Booking( $booking_id );
1712
1713 if ( ! $booking->is_booking() ) {
1714 return false;
1715 }
1716
1717 $stored_token = $booking->get_security_token();
1718
1719 if ( empty( $stored_token ) ) {
1720 return false;
1721 }
1722
1723 // constant-time comparison
1724 if ( ! hash_equals( $stored_token, $appointment_token ) ) {
1725 return false;
1726 }
1727 if ( 'pending' !== (string) $booking->get_status() ) {
1728 return false;
1729 }
1730
1731 return true;
1732 }
1733
1734 }
1735