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

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

1,959 lines 77.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Booking api
4 *
5 * @package Timetics
6 */
7 namespace Timetics\Core\Bookings;
8
9 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_booking = new Booking( $id );
940
941 // Reschedule only moves time.
942 if ( (int) $current_booking->get_appointment() !== $appointment ) {
943 return new WP_HTTP_Response(
944 [
945 'status_code' => 403,
946 'success' => 0,
947 'message' => esc_html__( 'You can not change the appointment of a booking.', 'timetics' ),
948 ],
949 403
950 );
951 }
952
953 $current_status = $current_booking->get_status();
954 if ( 'cancel' === $client_status ) {
955 $status = 'cancel';
956 } else {
957 $status = $current_status;
958 }
959 }
960 $appointment_token = ! empty( $data['appointment_token'] ) ? sanitize_text_field( $data['appointment_token'] ) : '';
961
962 if ( $id ) {
963 $email_validation = $this->validate_email_change_permission( $id, $email );
964
965 if ( is_wp_error( $email_validation ) ) {
966 $error_code = $email_validation->get_error_code();
967 $error_response = [
968 'success' => 0,
969 'status_code' => $error_code,
970 'message' => $email_validation->get_error_message(),
971 ];
972 return new WP_HTTP_Response( $error_response, $error_code );
973 }
974
975 // Use the validated email from the security check
976 $email = $email_validation;
977 }
978
979 $required_fields = [
980 'first_name',
981 'email',
982 'appointment',
983 'start_date',
984 'start_time',
985 'end_time',
986 ];
987
988 // Payment method is only chosen once, at booking creation. Later
989 // updates (status change, reschedule, staff swap, ...) shouldn't have
990 // to resubmit it — requiring it here made admin actions like
991 // cancelling from the calendar popover fail whenever the form didn't
992 // carry the original payment method in its state.
993 if ( 'created' === $action ) {
994 $required_fields[] = 'payment_method';
995 }
996
997 $validate = $this->validate( $data, $required_fields );
998
999 if ( is_wp_error( $validate ) ) {
1000 $data = [
1001 'status_code' => 403,
1002 'success' => 0,
1003 'message' => $validate->get_error_messages(),
1004 ];
1005 return new WP_HTTP_Response( $data, 403 );
1006 }
1007
1008 $customer = new Customer();
1009 $meeting = new Appointment( $appointment );
1010 $staff = new Staff( $staff_id );
1011 $booking = new Booking( $id );
1012 $booking_entry = new Booking_Entry();
1013
1014 // Validate booking
1015
1016 $validation = $this->validate_booking( $appointment, $data );
1017 if(is_wp_error($validation)){
1018 return $validation;
1019 }
1020
1021
1022
1023 if ( 'created' === $action && ! $this->is_available_slot( $meeting, [
1024 'staff_id' => $staff->get_id(),
1025 'start_date' => $start_date,
1026 'start_time' => $start_time,
1027 'timezone' => $timezone,
1028 ] ) ) {
1029 /* translators: %s: Time slot */
1030 return new WP_Error( 'time_slot_error', sprintf( __( '%s time slot is not available', 'timetics' ), $start_time ) );
1031 }
1032
1033 if ( $meeting->is_recurring() ) {
1034 $valid_recurrence = apply_filters( 'timetics_validate_recurring_booking', $recurring_dates, $start_time, $staff->get_id(), $meeting->get_id() );
1035
1036 if ( ! $valid_recurrence ) {
1037 $recurring_error = [
1038 'status_code' => 403,
1039 'success' => 0,
1040 'message' => __( 'Couldn\'t possible to book. Plese try another time.', 'timetics' ),
1041 ];
1042
1043 return new WP_HTTP_Response( $recurring_error, 403 );
1044 }
1045 }
1046
1047 $customer->make(
1048 [
1049 'first_name' => $first_name,
1050 'last_name' => $last_name,
1051 'email' => $email,
1052 'phone' => $phone,
1053 ]
1054 );
1055
1056 // Update booking schedule. Release the slot the booking currently holds;
1057 // the new one is taken further below.
1058 if ( $id ) {
1059 // Entries are stored in the meeting's timezone, so the booking's own
1060 // date/time has to be converted before the lookup. Without this the
1061 // entry is missed whenever the two timezones differ and it stays
1062 // behind blocking a slot nobody holds.
1063 $old_meeting = new Appointment( $booking->get_appointment() );
1064 $old_datetime = timetics_convert_timezone(
1065 $booking->get_start_date() . ' ' . $booking->get_start_time(),
1066 $booking->get_timezone(),
1067 $old_meeting->get_timezone()
1068 );
1069
1070 $entries = $booking_entry->find(
1071 [
1072 'staff_id' => $booking->get_staff_id(),
1073 'meeting_id' => $booking->get_appointment(),
1074 'date' => $old_datetime->format( 'Y-m-d' ),
1075 'start' => $old_datetime->format( 'h:i a' ),
1076 ]
1077 );
1078
1079 if ( $entries ) {
1080 $entry = $booking_entry->first();
1081
1082 if ( 'one-to-one' == strtolower( $old_meeting->get_type() ) ) {
1083 $entry->delete();
1084 } else {
1085 $booked = intval( $entry->get_booked() ) - 1;
1086 $booked_data = apply_filters( 'timetics_booking_update_schedule', $entry, ['booked' => $booked], $data, $booking );
1087 $entry->update( $this->normalize_schedule_update( $booked_data, $booked ) );
1088 }
1089 }
1090 }
1091
1092 if ( $id && $booking->get_status() == 'cancel' && $status == 'cancel' ) {
1093 return new WP_Error( 'booking_cancel_error', __( 'This booking alreay canceled', 'timetics' ) );
1094 }
1095
1096 $booking_props = [
1097 'customer' => $customer->get_id(),
1098 'appointment' => $meeting->get_id(),
1099 'appointment_name' => $meeting->get_name(),
1100 'staff' => $staff->get_id(),
1101 'customer_fname' => $customer->get_first_name(),
1102 'customer_lname' => $customer->get_last_name(),
1103 'customer_email' => $customer->get_email(),
1104 'customer_phone' => $customer->get_phone(),
1105 'staff_fname' => $staff->get_first_name(),
1106 'staff_lname' => $staff->get_last_name(),
1107 'staff_email' => $staff->get_email(),
1108 'meeting_name' => $meeting->get_name(),
1109 'meeting_description' => $meeting->get_description(),
1110 'meeting_type' => $meeting->get_type(),
1111 'booking_time' => $booking_time,
1112 'description' => $description,
1113 'start_date' => $start_date,
1114 'date' => $date,
1115 'end_date' => $end_date,
1116 'start_time' => $start_time,
1117 'end_time' => $end_time,
1118 'order_total' => ( $id && ! $is_privileged ) ? $booking->get_total() : $this->calculate_order_total( $data ),
1119 'post_status' => $status,
1120 'location' => $location,
1121 'location_type' => $location_type,
1122 'timezone' => $timezone,
1123 'cancel_reason' => $cancel_reason,
1124 ];
1125
1126 if ( 'created' === $action && '' !== $payment_method ) {
1127 $booking_props['payment_method'] = $payment_method;
1128 }
1129
1130 $old_meeting_timestamp = 0;
1131
1132 if ( $id ) {
1133 $old_start_date = $booking->get_start_date();
1134 $old_start_time = $booking->get_start_time();
1135 $old_end_time = $booking->get_end_time();
1136
1137 // Captured before the props are overwritten so pending delayed
1138 // flows can be matched against the meeting time they were frozen
1139 // with.
1140 $old_meeting_timestamp = Notification::get_booking_timestamp( $booking );
1141 }
1142
1143 if( 'created' == $action ){
1144 $booking_props['security_token'] = $booking->generate_security_token();
1145 }
1146
1147 $booking->set_props( $booking_props );
1148
1149
1150 $booking = apply_filters( 'timetics/bookings/booking/set', $booking );
1151
1152 $booking->save();
1153
1154 // Fire when booking is completed.
1155 do_action( 'timetics_after_booking_create', $booking->get_id(), $customer->get_id(), $meeting->get_id(), $data );
1156
1157 // Note: booking creation emails for new bookings are sent further below,
1158 // AFTER the calendar event is created, so the Google Meet join link is
1159 // available in the email. See the "created" branch after the schedule
1160 // entry is created.
1161
1162 // Create or update calendar event.
1163 if ( $id ) {
1164 if ( 'cancel' === $status ) {
1165 $booking->delete_event();
1166 $is_email_to_customer = timetics_get_option( 'booking_canceled_customer');
1167 $is_email_to_host = timetics_get_option( 'booking_canceled_host');
1168
1169 if ( $is_email_to_host ) {
1170 $cancel_event_email = new Cancel_Event_Email( $booking );
1171 $cancel_event_email->send();
1172 }
1173
1174 if ( $is_email_to_customer ) {
1175 $customer_cancel_event_email = new Cancel_Event_Customer_Email( $booking );
1176 $customer_cancel_event_email->send();
1177 }
1178
1179 do_action( 'timetics_gln_hook', 'booking_canceled', Notification::get_hook_data( $booking ) );
1180
1181 /**
1182 * Added temporary for leagacy sass. It will remove in future.
1183 */
1184 do_action( 'timetics/admin/booking/after_delete_item', $booking );
1185
1186 /**
1187 * Fired when an existing booking is cancelled.
1188 *
1189 * Cancel had no dedicated hook before, so integrations could
1190 * only react to create/reschedule/delete.
1191 *
1192 * @param int $booking_id Booking ID.
1193 * @param int $customer_id Customer ID.
1194 * @param int $meeting_id Meeting (appointment) ID.
1195 * @param array $data Request data.
1196 */
1197 do_action( 'timetics_after_booking_cancel', $booking->get_id(), $customer->get_id(), $meeting->get_id(), $data );
1198 } else {
1199 // Check if the booking date/time was actually changed
1200 $date_time_changed = (
1201 $old_start_date !== $start_date ||
1202 $old_start_time !== $start_time ||
1203 $old_end_time !== $end_time
1204 );
1205
1206 $booking->update_event();
1207
1208 if ( $date_time_changed ) {
1209 $reschedule_hook_data = Notification::get_hook_data( $booking );
1210
1211 // Move any pending delayed flow onto the new meeting time so
1212 // the reminder keeps its offset instead of firing at the old
1213 // moment with the old details.
1214 Notification_Flow_Guard::reschedule_pending_flows( $booking->get_id(), $reschedule_hook_data );
1215
1216 $is_email_to_reschedule_customer = timetics_get_option( 'booking_rescheduled_customer');
1217 $is_email_to_reschedule_host = timetics_get_option( 'booking_rescheduled_host');
1218
1219 if ( $is_email_to_reschedule_host ) {
1220 $update_event_email = new Update_Event_Email( $booking );
1221 $update_event_email->send();
1222 }
1223
1224 if ( $is_email_to_reschedule_customer ) {
1225 $update_event_customer_email = new Update_Event_Customer_Email( $booking );
1226 $update_event_customer_email->send();
1227 }
1228
1229 // Hand the previous meeting timestamp to the SDK as well —
1230 // its delay node uses `previous_<key>` to drop a checkpoint
1231 // it scheduled itself on an earlier run.
1232 if ( $old_meeting_timestamp ) {
1233 $reschedule_hook_data['previous_meeting_date_timestamp'] = $old_meeting_timestamp;
1234 }
1235
1236 do_action( 'timetics_gln_hook', 'booking_rescheduled', $reschedule_hook_data );
1237
1238 /**
1239 * Fired when a booking's date or time actually changed.
1240 *
1241 * `timetics_after_booking_schedule` runs on every save, so
1242 * it cannot tell a reschedule from an edit of the phone
1243 * number. This one only fires on a real time change.
1244 *
1245 * @param int $booking_id Booking ID.
1246 * @param int $customer_id Customer ID.
1247 * @param int $meeting_id Meeting (appointment) ID.
1248 * @param array $data Request data.
1249 */
1250 do_action( 'timetics_after_booking_reschedule', $booking->get_id(), $customer->get_id(), $meeting->get_id(), $data );
1251 }
1252 }
1253 }
1254
1255 // Convert booking time to staff/meeting time.
1256 $date_time = timetics_convert_timezone( $start_date . ' ' . $start_time, $timezone, $meeting->get_timezone() );
1257 $end_time = timetics_convert_timezone( $start_date . ' ' . $end_time, $timezone, $meeting->get_timezone() );
1258
1259 // Create booking schedule. Skipped on cancel — the slot for this
1260 // booking was already released above, and re-running this block would
1261 // either recreate the just-deleted entry (one-to-one) or double the
1262 // decrement (group), re-blocking or over-freeing the slot.
1263 if ( 'cancel' !== $status ) {
1264 $entries = $booking_entry->find(
1265 [
1266 'staff_id' => $staff->get_id(),
1267 'meeting_id' => $meeting->get_id(),
1268 'date' => $date_time->format( 'Y-m-d' ),
1269 'start' => $date_time->format( 'h:i a' ),
1270 ]
1271 );
1272
1273 if ( $entries ) {
1274 $entry = $booking_entry->first();
1275
1276 $booked = intval( $entry->get_booked() ) + 1;
1277 $booked_data = apply_filters( 'timetics_booking_update_schedule', $entry, ['booked' => $booked], $data, $booking );
1278
1279 $entry->update( $this->normalize_schedule_update( $booked_data, $booked ) );
1280 } else {
1281 $book_entry_data = [
1282 'meeting_id' => $meeting->get_id(),
1283 'staff_id' => $staff->get_id(),
1284 'customer_id' => $customer->get_id(),
1285 'booking_id' => $booking->get_id(),
1286 'booked' => 1,
1287 'date' => $date_time->format( 'Y-m-d' ),
1288 'start' => $date_time->format( 'h:i a' ),
1289 'end' => $end_time->format( 'h:i a' ),
1290 ];
1291
1292 $book_entry_data = apply_filters( 'timetics_booking_schedule', $book_entry_data, $data );
1293 $booking_entry->create( $book_entry_data );
1294 }
1295 }
1296
1297 // For newly created bookings, create the calendar event now that the
1298 // booking schedule entry exists. This generates the Google Meet link
1299 // (stored in booking meta) so it can be shown on the success page and
1300 // included in the notification emails sent below.
1301 //
1302 // Skipped while an online gateway payment is still outstanding — the
1303 // real event gets created once payment confirms, in make_payment() and
1304 // Hooks::update_booking_payment_status(). Based on payment_method and
1305 // amount alone, NOT $status: a privileged (logged-in admin/staff) user
1306 // gets $default_status regardless of gateway, which can be 'approved'
1307 // even though no payment happened yet — checking $status here would
1308 // miss that and create the event before the customer actually pays.
1309 $is_awaiting_online_payment = 'created' === $action && $server_total > 0
1310 && in_array( $payment_method_l, [ 'stripe', 'woocommerce', 'paypal' ], true );
1311
1312 if ( 'created' === $action && 'cancel' !== $status && ! $is_awaiting_online_payment ) {
1313 $booking->create_event();
1314 }
1315
1316 // Send booking creation emails for new bookings not processed through
1317 // a separate payment flow. Online gateways (stripe/paypal/woocommerce)
1318 // send this email themselves once payment is finalized, so excluding
1319 // them here avoids a duplicate email for the same booking. Sent here
1320 // (after create_event) so the Google Meet link is present in the email.
1321 if ( 'created' === $action && 'failed' !== $status && ! in_array( $payment_method_l, ['stripe', 'paypal', 'woocommerce'], true ) ) {
1322 $is_email_to_customer = timetics_get_option( 'booking_created_customer');
1323 $is_email_to_host = timetics_get_option( 'booking_created_host');
1324
1325 if ( $is_email_to_host ) {
1326 $new_event_email = new New_Event_Email( $booking );
1327 $new_event_email->send();
1328 }
1329
1330 if ( $is_email_to_customer ) {
1331 $new_event_customer_email = new New_Event_Customer_Email( $booking );
1332 $new_event_customer_email->send();
1333 }
1334
1335 do_action( 'timetics_gln_hook', 'booking_created', Notification::get_hook_data( $booking ) );
1336 }
1337
1338 // Fire after booking schedule create.
1339 do_action( 'timetics_after_booking_schedule', $booking->get_id(), $customer->get_id(), $meeting->get_id(), $data );
1340
1341 $data = [
1342 'success' => 1,
1343 'status_code' => 200,
1344 /* translators: Action */
1345 'message' => sprintf( esc_html__( 'Successfully %s booking', 'timetics' ), $action ),
1346 'data' => $this->prepare_item( $booking ),
1347 ];
1348
1349 return new WP_HTTP_Response( $data, 200 );
1350 }
1351
1352 /**
1353 * Prepare item for response
1354 *
1355 * @param integer $booking_id
1356 *
1357 * @return array
1358 */
1359 public function prepare_item( $booking_id, $expose_token = true ) {
1360 $booking = new Booking( $booking_id );
1361 $appointment = new Appointment( $booking->get_appointment() );
1362 $staff = new Staff( $booking->get_staff_id() );
1363 $customer = new Customer( $booking->get_customer_id() );
1364 $meeting_timezone = $appointment->get_timezone();
1365 $booking_timezone = $booking->get_timezone();
1366
1367 $start_date_time = timetics_convert_timezone( $booking->get_start_date() . ' ' . $booking->get_start_time(), $booking_timezone, $meeting_timezone );
1368 $end_date_time = timetics_convert_timezone( $booking->get_end_date() . ' ' . $booking->get_end_time(), $booking_timezone, $meeting_timezone );
1369 $date = timetics_datetime( 'Y-m-d', $booking->get_date(), $meeting_timezone );
1370
1371 $event = $booking->get_event();
1372 $join_link = 'google-meet' === $booking->get_location_type() && ! empty( $event['hangoutLink'] ) ? $event['hangoutLink'] : '';
1373
1374 $booking_title = $appointment->is_appointment() ? $appointment->get_name() : $booking->get_appointment_name();
1375
1376 $payment_details_raw = $booking->get_payment_details();
1377 $payment_details = is_array( $payment_details_raw ) ? $payment_details_raw : [];
1378
1379 $response = [
1380 'id' => $booking->get_id(),
1381 'random_id' => $booking->get_random_id(),
1382 'status' => $booking->get_status(),
1383 'order_total' => $booking->get_total(),
1384 'start_date' => $start_date_time->format( 'Y-m-d' ),
1385 'end_date' => $end_date_time->format( 'Y-m-d' ),
1386 'date' => $date,
1387 'start_time' => $start_date_time->format( 'h:i a' ),
1388 'end_time' => $end_date_time->format( 'h:i a' ),
1389 'booking_time' => $booking->get_booking_time(),
1390 'location' => $booking->get_location(),
1391 'location_type' => $booking->get_location_type(),
1392 'description' => $booking->get_description(),
1393 'cancel_reason' => $booking->get_cancel_reason(),
1394 // Listing endpoints (get_items / get_booking_list) pass $expose_token = false —
1395 // a viewer browsing many bookings at once has no legitimate need for every
1396 // one's bearer token; single-booking reads (create/get/update) keep it.
1397 'security_token' => $expose_token ? $booking->get_security_token() : '',
1398 'payment_method' => $booking->get_payment_method(),
1399 'payment_status' => $booking->get_payment_status(),
1400 'payment_details' => $payment_details,
1401 'customer' => [
1402 'id' => $customer->get_id(),
1403 'full_name' => $customer->get_display_name(),
1404 'first_name' => $customer->get_first_name(),
1405 'last_name' => $customer->get_last_name(),
1406 'email' => $customer->get_email(),
1407 'phone' => $customer->get_phone(),
1408 ],
1409 'appointment' => [
1410 'id' => $appointment->get_id(),
1411 'name' => $booking_title,
1412 'duration' => $appointment->get_duration(),
1413 'type' => $appointment->get_type(),
1414 'price' => $appointment->get_price(),
1415 'locations' => $appointment->get_locations(),
1416 'timezone' => $appointment->get_timezone(),
1417 'permalink' => $appointment->get_appointment_permalink(),
1418 ],
1419 'staff' => [
1420 'id' => $staff->get_id(),
1421 'full_name' => $staff->get_display_name(),
1422 'first_name' => $staff->get_first_name(),
1423 'last_name' => $staff->get_last_name(),
1424 'email_name' => $staff->get_email(),
1425 'phone' => $staff->get_phone(),
1426 'image' => $staff->get_image(),
1427 ],
1428 ];
1429
1430 if ( $join_link ) {
1431 $response['meeting_link'] = $join_link;
1432 }
1433
1434 return apply_filters( 'timetics_booking_json_data', $response, $booking );
1435 }
1436
1437 /**
1438 * Delete booking
1439 *
1440 * @param integer $booking_id
1441 *
1442 * @return bool
1443 */
1444 private function delete( $booking_id ) {
1445 $booking = new Booking( $booking_id );
1446 $meeting = new Appointment( $booking->get_appointment() );
1447
1448 if ( ! $booking->is_booking() ) {
1449 return false;
1450 }
1451
1452 $current_user_id = get_current_user_id();
1453
1454 if (
1455 $meeting->is_appointment()
1456 && ! user_can( $current_user_id, 'manage_options' )
1457 && $meeting->get_author() != $current_user_id
1458 ) {
1459 $data = [
1460 'success' => 0,
1461 'message' => __( 'You are not allowed to delete this booking.', 'timetics' ),
1462 ];
1463
1464 return new WP_HTTP_Response( $data, 403 );
1465 }
1466
1467
1468 $booking->release_slot();
1469
1470 $recurrences = $booking->get_recurrence();
1471
1472 /**
1473 * Fired before a booking is deleted, while its data can still be read.
1474 *
1475 * `timetics_after_booking_delete` runs after the post has already gone
1476 * and only receives the recurrence data, so an integration that needs
1477 * the booking, customer or meeting has to listen here instead.
1478 *
1479 * @param int $booking_id Booking ID.
1480 * @param int $customer_id Customer ID.
1481 * @param int $meeting_id Meeting (appointment) ID.
1482 * @param array $data Request data.
1483 */
1484 do_action( 'timetics_before_booking_delete', $booking->get_id(), $booking->get_customer_id(), $meeting->get_id(), [] );
1485
1486 $booking->delete_event();
1487 $booking->delete();
1488
1489 $is_email_to_customer = timetics_get_option( 'booking_canceled_customer');
1490 $is_email_to_host = timetics_get_option( 'booking_canceled_host');
1491
1492 if ( $is_email_to_host ) {
1493 $cancel_event_email = new Cancel_Event_Email( $booking );
1494 $cancel_event_email->send();
1495 }
1496
1497 if ( $is_email_to_customer ) {
1498
1499 $customer_cancel_event_email = new Cancel_Event_Customer_Email( $booking );
1500 $customer_cancel_event_email->send();
1501 }
1502
1503 do_action( 'timetics_gln_hook', 'booking_canceled', Notification::get_hook_data( $booking ) );
1504
1505
1506
1507 do_action( 'timetics_after_booking_delete', $recurrences );
1508
1509 return true;
1510 }
1511
1512 public function is_available_slot( $meeting, $booking_data = [] ) {
1513 $start_date = $booking_data['start_date'];
1514 $start_time = $booking_data['start_time'];
1515 $booking_timezone = $booking_data['timezone'];
1516 $booking_entry = new Booking_Entry();
1517 $meeting_id = $meeting->get_id();
1518 $staff_id = $booking_data['staff_id'];
1519
1520 $booking_entries = new Booking_Entry();
1521 $meeting = new Appointment( $meeting_id );
1522 $slot_datetime = timetics_convert_timezone( $start_date . ' ' . $start_time, $booking_timezone, $meeting->get_timezone() );
1523
1524 $entries = $booking_entries->find( [
1525 'meeting_id' => $meeting_id,
1526 'staff_id' => $staff_id,
1527 'date' => $slot_datetime->format( 'Y-m-d' ),
1528 'start' => $slot_datetime->format( 'h:i a' ),
1529 ] );
1530
1531 $booked = $entries ? $booking_entries->first() : false;
1532
1533 if ( $booked && intval( $booked->get_booked() ) >= $meeting->get_effective_capacity() ) {
1534 return false;
1535 }
1536
1537 /**
1538 * Let integrations veto a slot at booking time.
1539 *
1540 * Slot listing is filtered separately, so without this a client posting
1541 * straight to the REST endpoint could still book a slot that the UI
1542 * hides — which is how a Google Calendar conflict turned into a real
1543 * double booking. Integrations must fail open: return true when they
1544 * cannot determine availability.
1545 *
1546 * @param bool $available
1547 * @param Appointment $meeting
1548 * @param array $booking_data
1549 */
1550 return (bool) apply_filters( 'timetics_is_slot_available', true, $meeting, $booking_data );
1551 }
1552
1553 /**
1554 * Resolve what `timetics_booking_update_schedule` returned into an update payload.
1555 *
1556 * The filter passes the entry as its filtered value and the payload only as
1557 * an extra argument, so with nothing hooked it hands back the entry object.
1558 * Booking_Entry::update() then matches none of its keys and silently writes
1559 * nothing, leaving group counters frozen. Keep the published signature and
1560 * fall back to the payload whenever the result is not usable.
1561 *
1562 * @param mixed $filtered Whatever the filter returned.
1563 * @param integer $booked Counter this call meant to store.
1564 *
1565 * @return array
1566 */
1567 private function normalize_schedule_update( $filtered, $booked ) {
1568 return is_array( $filtered ) ? $filtered : [ 'booked' => $booked ];
1569 }
1570
1571 /**
1572 * Validates a booking.
1573 *
1574 * @param int $appointment_id The ID of the appointment.
1575 * @param array $data The data for the booking.
1576 * @throws None
1577 * @return mixed Returns an error response if the validation fails, otherwise returns nothing.
1578 */
1579 public function validate_booking($appointment_id, $data) {
1580 $meeting = new Appointment($appointment_id);
1581 $all_seats = (array) $meeting->get_seats();
1582 $meeting_price = $meeting->get_price();
1583 $meeting_locations = (array) $meeting->get_locations();
1584 $total_price = 0;
1585
1586 $staff_id = ! empty( $data['staff'] ) ? intval( $data['staff'] ) : 0;
1587 $order_total = ! empty( $data['order_total'] ) ? floatval( $data['order_total'] ) : 0;
1588 $location_type = ! empty( $data['location_type'] ) ? sanitize_text_field( $data['location_type'] ) : '';
1589 $start_date = ! empty( $data['start_date'] ) ? sanitize_text_field( $data['start_date'] ) : '';
1590 $timezone = ! empty( $data['timezone'] ) ? sanitize_text_field( $data['timezone'] ) : '';
1591 $start_time = ! empty( $data['start_time'] ) ? sanitize_text_field( $data['start_time'] ) : '';
1592 $status = ! empty( $data['status'] ) ? sanitize_text_field( $data['status'] ) : '';
1593 $seats = ! empty( $data['seats'] ) ? $data['seats'] : [];
1594 $timeslots = $meeting->get_avilable_timeslots( $start_date, $staff_id, $timezone );
1595 $meeting_has_buffer_time = $meeting->get_buffer_time_after_in_seconds() > 0 || $meeting->get_buffer_time_before_in_seconds() > 0;
1596
1597 if ( ! $meeting->is_appointment() ) {
1598 return $this->create_error_response( __( 'Invalid meeting.', 'timetics' ), 422 );
1599 }
1600
1601 if ( 'cancel' !== $status ) {
1602 if ( ! $meeting_has_buffer_time && ! in_array( gmdate( 'g:ia', strtotime( $start_time ) ), $timeslots ) ) {
1603 return $this->create_error_response( __( 'Invalid timeslot.', 'timetics' ), 422 );
1604 }
1605
1606 // Check if the staff is matched
1607 if ( ! in_array( $staff_id, $meeting->get_staff_ids() ) ) {
1608 return $this->create_error_response(__('Team member not matched', 'timetics'), 403);
1609
1610 }
1611 // Check if the location type is matched
1612 if ( ! in_array( $location_type, array_column( $meeting_locations, 'location_type' ) ) ) {
1613 return $this->create_error_response(__('Location type not matched', 'timetics'), 403);
1614 }
1615 }
1616 }
1617
1618 /**
1619 * Creates an error response with the given message and status code.
1620 *
1621 * @param string $message The error message.
1622 * @param int $status_code The HTTP status code.
1623 * @return WP_HTTP_Response The error response.
1624 */
1625 public function create_error_response($message, $status_code) {
1626 return new WP_Error( 'timezone_error', $message, ['status' => $status_code] );
1627 }
1628
1629 /**
1630 * Calculate order total
1631 *
1632 * @param array $data Request data
1633 *
1634 * @return integer
1635 */
1636 private function calculate_order_total($data) {
1637 $seats = ! empty( $data['seats'] ) ? $data['seats'] : [];
1638 $meeting_id = ! empty( $data['appointment'] ) ? $data['appointment'] : 0;
1639 $total_price = 0;
1640
1641 if ( class_exists( SeatPlan::class ) && $seats ) {
1642 foreach( $seats as $seat ) {
1643 $seat_object = SeatPlan::find( $seat );
1644 $total_price += $seat_object->price;
1645 }
1646
1647 return $total_price;
1648 }
1649
1650 $meeting = new Appointment( $meeting_id );
1651
1652 $prices = $meeting->get_price();
1653
1654 if ( $prices && is_array( $prices ) ) {
1655 return $prices[0]['ticket_price'];
1656 }
1657
1658 return 0;
1659 }
1660
1661 /**
1662 * Update item permission callback
1663 * @param WP_REST_Request $request
1664 * @return bool
1665 */
1666 public function update_item_permission_callback($request){
1667 $nonce = $request->get_header('X-WP-Nonce');
1668
1669 $booking_id = (int) $request->get_param('booking_id');
1670 $appointment_token = $request->get_param('appointment_token');
1671
1672 $booking = new Booking($booking_id);
1673
1674 if (!$booking->is_booking()) {
1675 return false;
1676 }
1677
1678 // Guests: must provide a valid token (constant-time compare).
1679 if ( ! empty( $appointment_token ) ) {
1680 $stored_token = (string) $booking->get_security_token();
1681 if ( '' !== $stored_token && hash_equals( $stored_token, (string) $appointment_token ) ) {
1682 return true;
1683 }
1684 }
1685
1686 if (empty($booking_id) || ! wp_verify_nonce($nonce, 'wp_rest')) {
1687 return false;
1688 }
1689
1690 // manage_timetics is not admin-only — every staff account holds it — so it
1691 // cannot stand in for an ownership check. Real admins, the booking's own
1692 // customer, or staff this specific booking is actually visible to.
1693 if (
1694 ( get_current_user_id() > 0 && (int) $booking->get_customer_id() === get_current_user_id() )
1695 || timetics_can_view_all_data()
1696 || in_array( $booking_id, timetics_get_visible_booking_ids(), true )
1697 ) {
1698 return true;
1699 }
1700
1701 return false;
1702 }
1703
1704 /**
1705 * Get item permission callback
1706 * @param WP_Rest_Request $request
1707 * @return bool
1708 */
1709 public function get_item_permission_callback($request){
1710 $nonce = $request->get_header('X-WP-Nonce');
1711 $booking_id = (int) $request->get_param('booking_id');
1712 $appointment_token = $request->get_param('appointment_token');
1713
1714 $booking = new Booking($booking_id);
1715
1716 if (!$booking->is_booking()) {
1717 return false;
1718 }
1719
1720 // Guests: must provide a valid token (constant-time compare).
1721 if ( ! empty( $appointment_token ) ) {
1722 $stored_token = (string) $booking->get_security_token();
1723 if ( '' !== $stored_token && hash_equals( $stored_token, (string) $appointment_token ) ) {
1724 return true;
1725 }
1726 }
1727
1728 if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
1729 return false;
1730 }
1731
1732 if (
1733 ( get_current_user_id() > 0 && (int) $booking->get_customer_id() === get_current_user_id() )
1734 || timetics_can_view_all_data()
1735 || in_array( $booking_id, timetics_get_visible_booking_ids(), true )
1736 ) {
1737 return true;
1738 }
1739
1740 return false;
1741 }
1742
1743 /**
1744 * Validate email change permission during booking update.
1745 *
1746 * Prevents non-admin users from reassigning bookings to other users
1747 * by changing the email address. Follows the principle of least privilege.
1748 *
1749 * @param int $booking_id The ID of the booking being updated.
1750 * @param string $new_email The new email address from the request.
1751 *
1752 * @return string|WP_Error Returns the validated email on success, WP_Error on failure.
1753 */
1754 private function validate_email_change_permission( $booking_id, $new_email ) {
1755 // manage_timetics is not admin-only — every staff account holds it.
1756 if ( timetics_can_view_all_data() ) {
1757 return $new_email;
1758 }
1759
1760 $existing_booking = new Booking( $booking_id );
1761
1762 if ( ! $existing_booking->is_booking() ) {
1763 return new WP_Error( 404, __( 'Booking not found.', 'timetics' ) );
1764 }
1765
1766 // Get original customer email
1767 $existing_customer = new Customer( $existing_booking->get_customer_id() );
1768 $original_email = $existing_customer->get_email();
1769
1770 if ( empty( $original_email ) ) {
1771 return new WP_Error( 500, __( 'Unable to verify booking ownership.', 'timetics' ) );
1772 }
1773
1774 // Check if email is being changed (case-insensitive comparison)
1775 $is_email_changed = ! empty( $new_email ) && strtolower( trim( $new_email ) ) !== strtolower( trim( $original_email ) );
1776
1777 if ( $is_email_changed ) {
1778 return new WP_Error( 403, __( 'You are not allowed to change the email address for this booking.', 'timetics' ) );
1779 }
1780
1781 return $original_email;
1782 }
1783
1784 /**
1785 * Bind a Stripe PaymentIntent to a booking by writing the booking_id and security_token into the PaymentIntent's metadata.
1786 *
1787 * @param \WP_REST_Request $request
1788 * @return \WP_HTTP_Response
1789 */
1790 public function bind_payment_intent( $request ) {
1791 $booking_id = (int) $request['booking_id'];
1792 $booking = new Booking( $booking_id );
1793
1794 if ( ! $booking->is_booking() ) {
1795 return new WP_HTTP_Response(
1796 [
1797 'success' => 0,
1798 'status_code' => 404,
1799 'message' => esc_html__( 'Invalid booking id.', 'timetics' ),
1800 ],
1801 404
1802 );
1803 }
1804
1805 $body = json_decode( $request->get_body(), true );
1806 $body = is_array( $body ) ? $body : [];
1807 $intent_id = ! empty( $body['payment_intent_id'] ) ? sanitize_text_field( (string) $body['payment_intent_id'] ) : '';
1808
1809 if ( '' === $intent_id || strpos( $intent_id, 'pi_' ) !== 0 ) {
1810 return new WP_HTTP_Response(
1811 [
1812 'success' => 0,
1813 'status_code' => 400,
1814 'message' => esc_html__( 'Invalid payment intent id.', 'timetics' ),
1815 ],
1816 400
1817 );
1818 }
1819
1820 $stripe = new StripePayment();
1821
1822 $bound = $booking->get_stripe_payment_intent_id();
1823 if ( '' !== $bound && $bound !== $intent_id ) {
1824 return new WP_HTTP_Response(
1825 [
1826 'success' => 0,
1827 'status_code' => 409,
1828 'message' => esc_html__( 'Booking already bound to another payment intent.', 'timetics' ),
1829 ],
1830 409
1831 );
1832 }
1833
1834 $intent = $stripe->retrieve_payment_intent( $intent_id );
1835
1836 if ( is_wp_error( $intent ) || ! is_array( $intent ) || empty( $intent['id'] ) ) {
1837 return new WP_HTTP_Response(
1838 [
1839 'success' => 0,
1840 'status_code' => 502,
1841 'message' => esc_html__( 'Cannot verify payment intent with Stripe.', 'timetics' ),
1842 ],
1843 502
1844 );
1845 }
1846
1847 $expected_amount = (int) round( (float) $booking->get_total() * 100 );
1848 $expected_currency = strtolower( (string) apply_filters( 'timetics_currency', timetics_get_option( 'currency', 'USD' ) ) );
1849 $intent_amount = isset( $intent['amount'] ) ? (int) $intent['amount'] : 0;
1850 $intent_currency = isset( $intent['currency'] ) ? strtolower( (string) $intent['currency'] ) : '';
1851 $intent_meta_book = isset( $intent['metadata']['booking_id'] ) ? (int) $intent['metadata']['booking_id'] : 0;
1852
1853 if ( $expected_amount <= 0 || $intent_amount !== $expected_amount || $intent_currency !== $expected_currency ) {
1854 return new WP_HTTP_Response(
1855 [
1856 'success' => 0,
1857 'status_code' => 409,
1858 'message' => esc_html__( 'Payment intent does not match this booking.', 'timetics' ),
1859 ],
1860 409
1861 );
1862 }
1863
1864 if ( 0 !== $intent_meta_book && $booking_id !== $intent_meta_book ) {
1865 return new WP_HTTP_Response(
1866 [
1867 'success' => 0,
1868 'status_code' => 409,
1869 'message' => esc_html__( 'Payment intent is bound to another booking.', 'timetics' ),
1870 ],
1871 409
1872 );
1873 }
1874
1875 // A previous decline released this booking's slot. Bind runs before the card
1876 // is charged, so it is the last safe point to take the slot back — refusing
1877 // here costs the customer nothing, refusing after payment would take their
1878 // money for a time somebody else now holds.
1879 if ( ! $booking->reserve_slot() ) {
1880 return new WP_HTTP_Response(
1881 [
1882 'success' => 0,
1883 'status_code' => 409,
1884 'message' => esc_html__( 'This time slot is no longer available. Please pick another time.', 'timetics' ),
1885 ],
1886 409
1887 );
1888 }
1889
1890 $result = $stripe->update_payment_intent(
1891 $intent_id,
1892 [
1893 'booking_id' => $booking_id,
1894 'security_token' => (string) $booking->get_security_token(),
1895 ]
1896 );
1897
1898 if ( is_wp_error( $result ) ) {
1899 return new WP_HTTP_Response(
1900 [
1901 'success' => 0,
1902 'status_code' => 502,
1903 'message' => $result->get_error_message(),
1904 ],
1905 502
1906 );
1907 }
1908
1909 // Record the intent id now (not just at make_payment finalize) so the
1910 // unpaid-booking cleanup sweep can check Stripe before cancelling.
1911 $booking->set_stripe_payment_intent_id( $intent_id );
1912
1913 return new WP_HTTP_Response(
1914 [
1915 'success' => 1,
1916 'status_code' => 200,
1917 'message' => esc_html__( 'Payment intent bound.', 'timetics' ),
1918 ],
1919 200
1920 );
1921 }
1922
1923 public function make_payment_permission_callback( $request ) {
1924
1925 $booking_id = (int) $request->get_param('booking_id');
1926 $appointment_token = sanitize_text_field( $request->get_param('appointment_token') );
1927
1928 if ( empty( $booking_id ) || empty( $appointment_token ) ) {
1929 return false;
1930 }
1931
1932 $booking = new Booking( $booking_id );
1933
1934 if ( ! $booking->is_booking() ) {
1935 return false;
1936 }
1937
1938 $stored_token = $booking->get_security_token();
1939
1940 if ( empty( $stored_token ) ) {
1941 return false;
1942 }
1943
1944 // constant-time comparison
1945 if ( ! hash_equals( $stored_token, $appointment_token ) ) {
1946 return false;
1947 }
1948 // A declined card leaves the booking 'failed' and the customer retries on that
1949 // same booking, so 'failed' has to pass too. Anything further along
1950 // ( approved / completed / cancelled ) is finished and must never be payable.
1951 if ( ! in_array( (string) $booking->get_status(), [ 'pending', 'failed' ], true ) ) {
1952 return false;
1953 }
1954
1955 return true;
1956 }
1957
1958 }
1959