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
← All changes | core/bookings/api-booking.php +1174 -197 1.0.11 → 1.0.64 View file →
@@ -5,18 +5,27 @@
5 5 * @package Timetics
6 6 */
7 7 namespace Timetics\Core\Bookings;
8 8
9 +defined( 'ABSPATH' ) || exit;
10 +
11 +use Error;
9 12 use Timetics\Base\Api;
13 +use Timetics\Core\Appointments\Api_Appointment;
10 14 use Timetics\Core\Appointments\Appointment;
11 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;
12 19 use Timetics\Core\Emails\Cancel_Event_Email;
13 20 use Timetics\Core\Emails\New_Event_Customer_Email;
14 21 use Timetics\Core\Emails\New_Event_Email;
15 22 use Timetics\Core\Emails\Update_Event_Customer_Email;
16 23 use Timetics\Core\Emails\Update_Event_Email;
24 +use Timetics\Core\Integrations\Stripe\StripePayment;
17 25 use Timetics\Core\Staffs\Staff;
18 26 use Timetics\Utils\Singleton;
27 +use TimeticsPro\Core\SeatPlan\SeatPlan;
19 28 use WP_Error;
20 29 use WP_HTTP_Response;
21 30 use WP_Query;
22 31
@@ -37,8 +46,15 @@
37 46 */
38 47 protected $rest_base = 'bookings';
39 48
40 49 /**
50 + * Booking Type
51 + *
52 + * @var string
53 + */
54 + protected $type = '';
55 +
56 + /**
41 57 * Register rest routes
42 58 *
43 59 * @return void
44 60 */
@@ -83,18 +99,14 @@
83 99 $this->namespace, '/' . $this->rest_base . '/(?P<booking_id>[\d]+)', [
84 100 [
85 101 'methods' => \WP_REST_Server::READABLE,
86 102 'callback' => [$this, 'get_item'],
87 - 'permission_callback' => function () {
88 - return true;
89 - },
103 + 'permission_callback' => [$this, 'get_item_permission_callback'],
90 104 ],
91 105 [
92 106 'methods' => \WP_REST_Server::EDITABLE,
93 107 'callback' => [$this, 'update_item'],
94 - 'permission_callback' => function () {
95 - return true;
96 - },
108 + 'permission_callback' => [$this, 'update_item_permission_callback'],
97 109 ],
98 110 [
99 111 'methods' => \WP_REST_Server::DELETABLE,
100 112 'callback' => [$this, 'delete_item'],
@@ -109,22 +121,32 @@
109 121 $this->namespace, '/' . $this->rest_base . '/(?P<booking_id>[\d]+)/payment', [
110 122 [
111 123 'methods' => \WP_REST_Server::EDITABLE,
112 124 'callback' => [$this, 'make_payment'],
113 - 'permission_callback' => function () {
114 - return true;
115 - },
125 + 'permission_callback' => [$this, 'make_payment_permission_callback'],
116 126 ],
117 127 ]
118 128 );
119 129
120 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(
121 141 $this->namespace, $this->rest_base . '/search', [
122 142 [
123 143 'methods' => \WP_REST_Server::READABLE,
124 144 'callback' => [$this, 'search_items'],
125 145 'permission_callback' => function () {
126 - return current_user_can( 'edit_posts' );
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' );
127 149 },
128 150 ],
129 151 ]
130 152 );
@@ -165,27 +187,31 @@
165 187 $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 20;
166 188 $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
167 189 $meeting_id = ! empty( $request['meeting_id'] ) ? intval( $request['meeting_id'] ) : 0;
168 190 $start_date = ! empty( $request['start_date'] ) ? $request['start_date'] : '';
169 - $staff_id = ! current_user_can( 'edit_booking' ) ? get_current_user_id() : 0;
170 191
171 192 $args = [
172 193 'posts_per_page' => $per_page,
173 194 'paged' => $paged,
174 195 'meeting' => $meeting_id,
175 - 'staff' => $staff_id,
176 196 ];
177 197
198 + $args = apply_filters( 'timetics/add/item/data', $args, $request );
199 +
178 200 if ( $start_date ) {
179 201 $args['start_date'] = $start_date;
180 202 }
181 203
182 - $appoint = Booking::all( $args );
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 + }
183 208
184 - $items = [];
209 + $bookings = Booking::all( $args );
210 + $items = [];
185 211
186 - foreach ( $appoint['items'] as $item ) {
187 - $items[] = $this->prepare_item( $item->ID );
212 + foreach ( $bookings['items'] as $item ) {
213 + $items[] = $this->prepare_item( $item->ID, false );
188 214 }
189 215
190 216 /**
191 217 * Added temporary for leagacy sass. It will remove in future.
@@ -195,9 +221,9 @@
195 221 $data = [
196 222 'success' => 1,
197 223 'status_code' => 200,
198 224 'data' => [
199 - 'total' => $appoint['total'],
225 + 'total' => $bookings['total'],
200 226 'items' => $items,
201 227 ],
202 228 ];
203 229
@@ -245,19 +271,38 @@
245 271 *
246 272 * @return JSON
247 273 */
248 274 public function create_item( $request ) {
249 - /**
250 - * Added temporary for leagacy sass. It will remove in future.
251 - */
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 +
252 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 ) {
253 298 $response = [
254 - 'status_code' => 409,
299 + 'status_code' => 403,
255 300 'success' => 0,
256 - 'message' => esc_html__( 'Manual Booking Restricted ', 'timetics' ),
301 + 'message' => esc_html__( 'Recurring booking limit exit', 'timetics' ),
257 302 ];
258 303
259 - return rest_ensure_response( $response );
304 + return new WP_HTTP_Response( $response, 403 );
260 305 } // End.
261 306
262 307 return $this->save_bookings( $request );
263 308 }
@@ -269,8 +314,9 @@
269 314 *
270 315 * @return JSON
271 316 */
272 317 public function update_item( $request ) {
318 +
273 319 $booking_id = (int) $request['booking_id'];
274 320 $booking = new Booking( $booking_id );
275 321
276 322 if ( ! $booking->is_booking() ) {
@@ -280,8 +326,18 @@
280 326 'data' => [],
281 327 ];
282 328 }
283 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 +
284 340 return $this->save_bookings( $request, $booking_id );
285 341 }
286 342
287 343 /**
@@ -291,8 +347,9 @@
291 347 *
292 348 * @return JSON
293 349 */
294 350 public function delete_item( $request ) {
351 +
295 352 $booking_id = (int) $request['booking_id'];
296 353
297 354 $delete = $this->delete( $booking_id );
298 355
@@ -324,8 +381,9 @@
324 381 *
325 382 * @return JSON
326 383 */
327 384 public function bulk_delete( $request ) {
385 +
328 386 $bookings = json_decode( $request->get_body(), true );
329 387
330 388 foreach ( $bookings as $booking ) {
331 389 $delete = $this->delete( $booking );
@@ -375,21 +433,31 @@
375 433 *
376 434 * @return JSON
377 435 */
378 436 public function search_items( $request ) {
437 +
379 438 // Prepare search args.
380 439 $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 20;
381 440 $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
382 441 $search = ! empty( $request['search'] ) ? sanitize_text_field( $request['search'] ) : '';
383 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 +
384 455 // Get search.
385 456 $booking = new WP_Query(
386 - array(
387 - 'post_type' => 'timetics-booking',
388 - 'posts_per_page' => $per_page,
389 - 'paged' => $paged,
390 - 'post_status' => 'any',
391 -
457 + array_merge(
458 + $query_args,
459 + array(
392 460 // @codingStandardsIgnoreStart
393 461 'meta_query' => array(
394 462 'relation' => 'OR',
395 463 array(
@@ -443,8 +511,9 @@
443 511 'compare' => 'LIKE',
444 512 ),
445 513 ),
446 514 // @codingStandardsIgnoreEnd
515 + )
447 516 )
448 517 );
449 518
450 519 // Prepare items for response.
@@ -450,9 +519,9 @@
450 519 // Prepare items for response.
451 520 $items = [];
452 521
453 522 foreach ( $booking->posts as $item ) {
454 - $items[] = $this->prepare_item( $item->ID );
523 + $items[] = $this->prepare_item( $item->ID, false );
455 524 }
456 525
457 526 /**
458 527 * Added temporary for leagacy sass. It will remove in future.
@@ -497,8 +566,9 @@
497 566 return new WP_Error( 'timezone_error', __( 'Your meeting timezone is invalid. Please update your meeting timezone with proper timezone.', 'timetics' ) );
498 567 }
499 568
500 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 );
501 571
502 572 $data = [
503 573 'today' => gmdate( 'Y-m-d' ),
504 574 'availability_timezone' => $meeting->get_timezone(),
@@ -525,28 +595,186 @@
525 595 *
526 596 * @return JSON
527 597 */
528 598 public function make_payment( $request ) {
529 - $booking_id = intval( $request['booking_id'] );
530 - $booking = new Booking( $booking_id );
531 - $data = json_decode( $request->get_body(), true );
532 - $status = ! empty( $data['status'] ) ? sanitize_text_field( $data['status'] ) : '';
533 - $post_status = 'succeeded' === $status ? 'completed' : 'pending';
534 - $payment_method = ! empty( $data['payment_method'] ) ? sanitize_text_field( $data['payment_method'] ) : '';
535 - $payment_details = ! empty( $data['payment_details'] ) ? $data['payment_details'] : '';
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();
536 607
537 608 if ( ! $booking->is_booking() ) {
538 - return [
539 - 'status_code' => 404,
540 - 'message' => esc_html__( 'Invalid booking id.', 'timetics' ),
541 - 'data' => [],
542 - ];
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 + );
543 617 }
544 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 +
545 773 $update = $booking->update(
546 774 [
547 775 'post_status' => $post_status,
548 - 'payment_status' => $status,
776 + 'payment_status' => $verified_status,
549 777 'payment_details' => $payment_details,
550 778 'payment_method' => $payment_method,
551 779 ]
552 780 );
@@ -551,25 +779,62 @@
551 779 ]
552 780 );
553 781
554 782 if ( is_wp_error( $update ) ) {
555 - $data = [
556 - 'success' => 0,
557 - 'status_code' => 409,
558 - /* translators: Action */
559 - 'message' => $update->get_error_message(),
560 - ];
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 + }
561 797
562 - return new WP_HTTP_Response( $data, 409 );
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();
563 802 }
564 803
565 - if ( 'completed' === $post_status ) {
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 +
566 814 $booking->create_event();
567 - $new_event_email = new New_Event_Email( $booking );
568 - $new_event_email->send();
569 815
570 - $new_event_customer_email = new New_Event_Customer_Email( $booking );
571 - $new_event_customer_email->send();
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 +
572 837 }
573 838
574 839 /**
575 840 * Added temporary for leagacy sass. It will remove in future.
@@ -596,12 +861,41 @@
596 861 */
597 862 public function save_bookings( $request, $id = 0 ) {
598 863 $data = json_decode( $request->get_body(), true );
599 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) {
600 882 $first_name = ! empty( $data['first_name'] ) ? sanitize_text_field( $data['first_name'] ) : '';
601 883 $last_name = ! empty( $data['last_name'] ) ? sanitize_text_field( $data['last_name'] ) : '';
602 884 $email = ! empty( $data['email'] ) ? sanitize_text_field( $data['email'] ) : '';
603 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 + }
604 898 $city = ! empty( $data['city'] ) ? sanitize_text_field( $data['city'] ) : '';
605 899 $state = ! empty( $data['state'] ) ? sanitize_text_field( $data['state'] ) : '';
606 900 $post_code = ! empty( $data['post_code'] ) ? sanitize_text_field( $data['post_code'] ) : '';
607 901 $country = ! empty( $data['country'] ) ? sanitize_text_field( $data['country'] ) : '';
@@ -608,35 +902,101 @@
608 902 $payment_method = ! empty( $data['payment_method'] ) ? sanitize_text_field( $data['payment_method'] ) : '';
609 903 $address_1 = ! empty( $data['address_1'] ) ? sanitize_text_field( $data['address_1'] ) : '';
610 904 $address_2 = ! empty( $data['address_2'] ) ? sanitize_text_field( $data['address_2'] ) : '';
611 905 $appointment = ! empty( $data['appointment'] ) ? intval( $data['appointment'] ) : 0;
612 - $staff = ! empty( $data['staff'] ) ? intval( $data['staff'] ) : 0;
906 + $staff_id = ! empty( $data['staff'] ) ? intval( $data['staff'] ) : 0;
613 907 $start_date = ! empty( $data['start_date'] ) ? sanitize_text_field( $data['start_date'] ) : '';
614 908 $date = ! empty( $data['date'] ) ? sanitize_text_field( $data['date'] ) : '';
615 909 $end_date = ! empty( $data['end_date'] ) ? sanitize_text_field( $data['end_date'] ) : $start_date;
616 910 $start_time = ! empty( $data['start_time'] ) ? sanitize_text_field( $data['start_time'] ) : '';
617 911 $end_time = ! empty( $data['end_time'] ) ? sanitize_text_field( $data['end_time'] ) : '';
618 - $order_total = ! empty( $data['order_total'] ) ? intval( $data['order_total'] ) : 0;
619 - $status = ! empty( $data['status'] ) ? sanitize_text_field( $data['status'] ) : timetics_get_option( 'default_booking_status' );
912 + $client_status = ! empty( $data['status'] ) ? sanitize_text_field( $data['status'] ) : '';
620 913 $location = ! empty( $data['location'] ) ? sanitize_text_field( $data['location'] ) : '';
621 914 $location_type = ! empty( $data['location_type'] ) ? sanitize_text_field( $data['location_type'] ) : '';
622 915 $description = ! empty( $data['description'] ) ? sanitize_text_field( $data['description'] ) : '';
623 916 $timezone = ! empty( $data['timezone'] ) ? sanitize_text_field( $data['timezone'] ) : '';
624 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'] : '';
625 921 $action = $id ? 'updated' : 'created';
626 922
627 - $validate = $this->validate(
628 - $data, [
629 - 'first_name',
630 - 'email',
631 - 'payment_method',
632 - 'appointment',
633 - 'start_date',
634 - 'start_time',
635 - 'end_time',
636 - ]
637 - );
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 );
638 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 +
639 999 if ( is_wp_error( $validate ) ) {
640 1000 $data = [
641 1001 'status_code' => 403,
642 1002 'success' => 0,
@@ -644,12 +1004,23 @@
644 1004 ];
645 1005 return new WP_HTTP_Response( $data, 403 );
646 1006 }
647 1007
648 - $customer = new Customer();
649 - $meeting = new Appointment( $appointment );
650 - $staff = new Staff( $staff );
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();
651 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 +
652 1023 if ( 'created' === $action && ! $this->is_available_slot( $meeting, [
653 1024 'staff_id' => $staff->get_id(),
654 1025 'start_date' => $start_date,
655 1026 'start_time' => $start_time,
@@ -654,8 +1025,9 @@
654 1025 'start_date' => $start_date,
655 1026 'start_time' => $start_time,
656 1027 'timezone' => $timezone,
657 1028 ] ) ) {
1029 + /* translators: %s: Time slot */
658 1030 return new WP_Error( 'time_slot_error', sprintf( __( '%s time slot is not available', 'timetics' ), $start_time ) );
659 1031 }
660 1032
661 1033 if ( $meeting->is_recurring() ) {
@@ -680,133 +1052,293 @@
680 1052 'phone' => $phone,
681 1053 ]
682 1054 );
683 1055
684 - $booking = new Booking( $id );
685 - $booking_entry = new Booking_Entry();
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 + );
686 1069
687 - // Update booking schedule.
688 - if ( $id ) {
689 1070 $entries = $booking_entry->find(
690 1071 [
691 1072 'staff_id' => $booking->get_staff_id(),
692 1073 'meeting_id' => $booking->get_appointment(),
693 - 'date' => $booking->get_start_date(),
694 - 'start' => $booking->get_start_time(),
1074 + 'date' => $old_datetime->format( 'Y-m-d' ),
1075 + 'start' => $old_datetime->format( 'h:i a' ),
695 1076 ]
696 1077 );
697 -
1078 +
698 1079 if ( $entries ) {
699 1080 $entry = $booking_entry->first();
700 1081
701 - if ( 'one-to-one' == strtolower( $meeting->get_type() ) ) {
1082 + if ( 'one-to-one' == strtolower( $old_meeting->get_type() ) ) {
702 1083 $entry->delete();
703 1084 } else {
704 1085 $booked = intval( $entry->get_booked() ) - 1;
705 1086 $booked_data = apply_filters( 'timetics_booking_update_schedule', $entry, ['booked' => $booked], $data, $booking );
706 - $entry->update( $booked_data );
1087 + $entry->update( $this->normalize_schedule_update( $booked_data, $booked ) );
707 1088 }
708 1089 }
709 1090 }
710 1091
711 - $booking->set_props(
712 - [
713 - 'customer' => $customer->get_id(),
714 - 'appointment' => $meeting->get_id(),
715 - 'staff' => $staff->get_id(),
716 - 'customer_fname' => $customer->get_first_name(),
717 - 'customer_lname' => $customer->get_last_name(),
718 - 'customer_email' => $customer->get_email(),
719 - 'customer_phone' => $customer->get_phone(),
720 - 'staff_fname' => $staff->get_first_name(),
721 - 'staff_lname' => $staff->get_last_name(),
722 - 'staff_email' => $staff->get_email(),
723 - 'meeting_name' => $meeting->get_name(),
724 - 'meeting_description' => $meeting->get_description(),
725 - 'meeting_type' => $meeting->get_type(),
726 - 'description' => $description,
727 - 'start_date' => $start_date,
728 - 'date' => $date,
729 - 'end_date' => $end_date,
730 - 'start_time' => $start_time,
731 - 'end_time' => $end_time,
732 - 'order_total' => $order_total,
733 - 'post_status' => $status,
734 - 'location' => $location,
735 - 'location_type' => $location_type,
736 - 'timezone' => $timezone,
737 - ]
738 - );
1092 + if ( $id && $booking->get_status() == 'cancel' && $status == 'cancel' ) {
1093 + return new WP_Error( 'booking_cancel_error', __( 'This booking alreay canceled', 'timetics' ) );
1094 + }
739 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 +
740 1152 $booking->save();
741 1153
742 1154 // Fire when booking is completed.
743 1155 do_action( 'timetics_after_booking_create', $booking->get_id(), $customer->get_id(), $meeting->get_id(), $data );
744 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 +
745 1162 // Create or update calendar event.
746 1163 if ( $id ) {
747 1164 if ( 'cancel' === $status ) {
748 1165 $booking->delete_event();
749 - $cancel_event_email = new Cancel_Event_Email( $booking );
750 - $cancel_event_email->send();
1166 + $is_email_to_customer = timetics_get_option( 'booking_canceled_customer');
1167 + $is_email_to_host = timetics_get_option( 'booking_canceled_host');
751 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 +
752 1181 /**
753 1182 * Added temporary for leagacy sass. It will remove in future.
754 1183 */
755 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 );
756 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 +
757 1206 $booking->update_event();
758 - $update_event_email = new Update_Event_Email( $booking );
759 - $update_event_email->send();
760 1207
761 - $update_event_customer_email = new Update_Event_Customer_Email( $booking );
762 - $update_event_customer_email->send();
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 + }
763 1252 }
764 1253 }
765 1254
766 - // Create booking schedule.
767 - $entries = $booking_entry->find(
768 - [
769 - 'staff_id' => $staff->get_id(),
770 - 'meeting_id' => $meeting->get_id(),
771 - 'date' => $start_date,
772 - 'start' => $start_time,
773 - ]
774 - );
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() );
775 1258
776 - if ( $entries ) {
777 - $entry = $booking_entry->first();
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 + );
778 1272
779 - if ( 'cancel' === $status ) {
780 - $booked = intval( $entry->get_booked() ) - 1;
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 ) );
781 1280 } else {
782 - $booked = intval( $entry->get_booked() ) + 1;
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 );
783 1294 }
1295 + }
784 1296
785 - $booked_data = apply_filters( 'timetics_booking_update_schedule', $entry, ['booked' => $booked], $data, $booking );
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 );
786 1311
787 - if ( 'cancel' === $status && 'one-to-one' == strtolower( $meeting->get_type() ) ) {
788 - $entry->delete();
789 - } else {
790 - $entry->update( $booked_data );
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();
791 1328 }
792 1329
793 - } else {
794 - $book_entry_data = [
795 - 'meeting_id' => $meeting->get_id(),
796 - 'staff_id' => $staff->get_id(),
797 - 'customer_id' => $customer->get_id(),
798 - 'booking_id' => $booking->get_id(),
799 - 'booked' => 1,
800 - 'date' => $start_date,
801 - 'start' => $start_time,
802 - 'end' => $end_time,
803 - ];
1330 + if ( $is_email_to_customer ) {
1331 + $new_event_customer_email = new New_Event_Customer_Email( $booking );
1332 + $new_event_customer_email->send();
1333 + }
804 1334
805 - $book_entry_data = apply_filters( 'timetics_booking_schedule', $book_entry_data, $data );
806 - $booking_entry->create( $book_entry_data );
1335 + do_action( 'timetics_gln_hook', 'booking_created', Notification::get_hook_data( $booking ) );
807 1336 }
808 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 +
809 1341 $data = [
810 1342 'success' => 1,
811 1343 'status_code' => 200,
812 1344 /* translators: Action */
@@ -823,9 +1355,9 @@
823 1355 * @param integer $booking_id
824 1356 *
825 1357 * @return array
826 1358 */
827 - public function prepare_item( $booking_id ) {
1359 + public function prepare_item( $booking_id, $expose_token = true ) {
828 1360 $booking = new Booking( $booking_id );
829 1361 $appointment = new Appointment( $booking->get_appointment() );
830 1362 $staff = new Staff( $booking->get_staff_id() );
831 1363 $customer = new Customer( $booking->get_customer_id() );
@@ -838,21 +1370,35 @@
838 1370
839 1371 $event = $booking->get_event();
840 1372 $join_link = 'google-meet' === $booking->get_location_type() && ! empty( $event['hangoutLink'] ) ? $event['hangoutLink'] : '';
841 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 +
842 1379 $response = [
843 - 'id' => $booking->get_id(),
844 - 'random_id' => $booking->get_random_id(),
845 - 'status' => $booking->get_status(),
846 - 'order_total' => $booking->get_total(),
847 - 'start_date' => $start_date_time->format( 'Y-m-d' ),
848 - 'end_date' => $end_date_time->format( 'Y-m-d' ),
849 - 'date' => $date,
850 - 'start_time' => $start_date_time->format( 'h:i a' ),
851 - 'end_time' => $end_date_time->format( 'h:i a' ),
852 - 'location' => $booking->get_location(),
853 - 'location_type' => $booking->get_location_type(),
854 - 'description' => $booking->get_description(),
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,
855 1401 'customer' => [
856 1402 'id' => $customer->get_id(),
857 1403 'full_name' => $customer->get_display_name(),
858 1404 'first_name' => $customer->get_first_name(),
@@ -861,9 +1407,9 @@
861 1407 'phone' => $customer->get_phone(),
862 1408 ],
863 1409 'appointment' => [
864 1410 'id' => $appointment->get_id(),
865 - 'name' => $appointment->get_name(),
1411 + 'name' => $booking_title,
866 1412 'duration' => $appointment->get_duration(),
867 1413 'type' => $appointment->get_type(),
868 1414 'price' => $appointment->get_price(),
869 1415 'locations' => $appointment->get_locations(),
@@ -902,41 +1448,63 @@
902 1448 if ( ! $booking->is_booking() ) {
903 1449 return false;
904 1450 }
905 1451
906 - $booking_entry = new Booking_Entry();
907 - $entries = $booking_entry->find(
908 - [
909 - 'staff_id' => $booking->get_staff_id(),
910 - 'meeting_id' => $meeting->get_id(),
911 - 'date' => $booking->get_start_date(),
912 - 'start' => $booking->get_start_time(),
913 - ]
914 - );
1452 + $current_user_id = get_current_user_id();
915 1453
916 - if ( $entries ) {
917 - $entry = $booking_entry->first();
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 + ];
918 1463
919 - if ( 'one-to-one' == strtolower( $meeting->get_type() ) ) {
920 - $entry->delete();
921 - } else {
922 - $booked = intval( $entry->get_booked() ) - 1;
923 - $booked_seat = ! empty( $booking->get_seat() ) ? $booking->get_seat() : [];
924 - $existing_seat = ! empty( $entry->get_seats() ) ? $entry->get_seats() : [];
1464 + return new WP_HTTP_Response( $data, 403 );
1465 + }
925 1466
926 - $entry->update( [
927 - 'booked' => $booked,
928 - 'seats' => array_values( array_diff( $existing_seat, $booked_seat ) ),
929 - ] );
930 - }
931 - }
932 1467
1468 + $booking->release_slot();
1469 +
933 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 +
934 1486 $booking->delete_event();
935 1487 $booking->delete();
936 - $cancel_event_email = new Cancel_Event_Email( $booking );
937 - $cancel_event_email->send();
938 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 +
939 1507 do_action( 'timetics_after_booking_delete', $recurrences );
940 1508
941 1509 return true;
942 1510 }
@@ -948,34 +1516,443 @@
948 1516 $booking_entry = new Booking_Entry();
949 1517 $meeting_id = $meeting->get_id();
950 1518 $staff_id = $booking_data['staff_id'];
951 1519
952 - $time = is_string( $start_time ) ? strtotime( $start_time ) : $start_time;
953 - $time = gmdate( 'H:i', $time );
954 1520 $booking_entries = new Booking_Entry();
955 1521 $meeting = new Appointment( $meeting_id );
1522 + $slot_datetime = timetics_convert_timezone( $start_date . ' ' . $start_time, $booking_timezone, $meeting->get_timezone() );
956 1523
957 1524 $entries = $booking_entries->find( [
958 1525 'meeting_id' => $meeting_id,
959 1526 'staff_id' => $staff_id,
960 - 'date' => $start_date,
1527 + 'date' => $slot_datetime->format( 'Y-m-d' ),
1528 + 'start' => $slot_datetime->format( 'h:i a' ),
961 1529 ] );
962 1530
963 - $booked = false;
1531 + $booked = $entries ? $booking_entries->first() : false;
964 1532
965 - foreach ( $entries as $entry ) {
966 - $booking = new Booking( $entry->get_booking_id() );
967 - $booking_time = timetics_convert_timezone( $booking->get_start_date() . ' ' . $entry->get_start(), $booking->get_timezone(), $booking_timezone )->format( 'H:i' );
1533 + if ( $booked && intval( $booked->get_booked() ) >= $meeting->get_effective_capacity() ) {
1534 + return false;
1535 + }
968 1536
969 - if ( $booking_time == $time ) {
970 - $booked = $entry;
971 - break;
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 );
972 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 + }
973 1615 }
1616 + }
974 1617
975 - if ( $booked && $booked->get_booked() >= $meeting->get_capacity() ) {
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()) {
976 1675 return false;
977 1676 }
978 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 +
979 1955 return true;
980 1956 }
1957 +
981 1958 }