PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / 1.0.63
Timetics – Appointment Booking Calendar & Scheduling v1.0.63
1.0.62 1.0.63 1.0.61 1.0.60 1.0.59 1.0.58 1.0.57 1.0.56 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.19 1.0.2 1.0.20 1.0.21 1.0.22 All 64 releases
timetics / core / bookings / api-booking.php

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

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