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 +297 -107 1.0.59 → 1.0.64 View file →
@@ -5,8 +5,10 @@
5 5 * @package Timetics
6 6 */
7 7 namespace Timetics\Core\Bookings;
8 8
9 +defined( 'ABSPATH' ) || exit;
10 +
9 11 use Error;
10 12 use Timetics\Base\Api;
11 13 use Timetics\Core\Appointments\Api_Appointment;
12 14 use Timetics\Core\Appointments\Appointment;
@@ -11,8 +13,9 @@
11 13 use Timetics\Core\Appointments\Api_Appointment;
12 14 use Timetics\Core\Appointments\Appointment;
13 15 use Timetics\Core\Customers\Customer;
14 16 use Timetics\Core\Admin\Notification;
17 +use Timetics\Core\Admin\Notification_Flow_Guard;
15 18 use Timetics\Core\Emails\Cancel_Event_Customer_Email;
16 19 use Timetics\Core\Emails\Cancel_Event_Email;
17 20 use Timetics\Core\Emails\New_Event_Customer_Email;
18 21 use Timetics\Core\Emails\New_Event_Email;
@@ -139,9 +142,11 @@
139 142 [
140 143 'methods' => \WP_REST_Server::READABLE,
141 144 'callback' => [$this, 'search_items'],
142 145 'permission_callback' => function () {
143 - 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' );
144 149 },
145 150 ],
146 151 ]
147 152 );
@@ -204,9 +209,9 @@
204 209 $bookings = Booking::all( $args );
205 210 $items = [];
206 211
207 212 foreach ( $bookings['items'] as $item ) {
208 - $items[] = $this->prepare_item( $item->ID );
213 + $items[] = $this->prepare_item( $item->ID, false );
209 214 }
210 215
211 216 /**
212 217 * Added temporary for leagacy sass. It will remove in future.
@@ -434,16 +439,25 @@
434 439 $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 20;
435 440 $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
436 441 $search = ! empty( $request['search'] ) ? sanitize_text_field( $request['search'] ) : '';
437 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 +
438 455 // Get search.
439 456 $booking = new WP_Query(
440 - array(
441 - 'post_type' => 'timetics-booking',
442 - 'posts_per_page' => $per_page,
443 - 'paged' => $paged,
444 - 'post_status' => 'any',
445 -
457 + array_merge(
458 + $query_args,
459 + array(
446 460 // @codingStandardsIgnoreStart
447 461 'meta_query' => array(
448 462 'relation' => 'OR',
449 463 array(
@@ -497,8 +511,9 @@
497 511 'compare' => 'LIKE',
498 512 ),
499 513 ),
500 514 // @codingStandardsIgnoreEnd
515 + )
501 516 )
502 517 );
503 518
504 519 // Prepare items for response.
@@ -504,9 +519,9 @@
504 519 // Prepare items for response.
505 520 $items = [];
506 521
507 522 foreach ( $booking->posts as $item ) {
508 - $items[] = $this->prepare_item( $item->ID );
523 + $items[] = $this->prepare_item( $item->ID, false );
509 524 }
510 525
511 526 /**
512 527 * Added temporary for leagacy sass. It will remove in future.
@@ -601,10 +616,12 @@
601 616 );
602 617 }
603 618
604 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.
605 622 $current_status = (string) $booking->get_status();
606 - $finalized_statuses = [ 'approved', 'completed', 'failed', 'cancelled', 'cancel' ];
623 + $finalized_statuses = [ 'approved', 'completed', 'cancelled', 'cancel' ];
607 624 if ( in_array( $current_status, $finalized_statuses, true ) ) {
608 625 return new WP_HTTP_Response(
609 626 [
610 627 'success' => 0,
@@ -702,8 +719,18 @@
702 719 }
703 720 } elseif ( 'failed' === $client_status ) {
704 721 // Marking the user's own attempt as failed never grants access; safe to honor.
705 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 + }
706 733 }
707 734 // Other payment methods (cash, on-site, etc.) stay pending here. They
708 735 // are approved through their own authenticated/admin paths.
709 736 $post_status = 'succeeded' === $verified_status
@@ -712,11 +739,14 @@
712 739
713 740 $finalizing = 'succeeded' === $verified_status && '' !== $stored_intent_id;
714 741
715 742 if ( $finalizing ) {
716 - $claimed = add_post_meta( $booking_id, '_tt_stripe_payment_intent_id', $stored_intent_id, true );
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 );
717 747 if ( false === $claimed ) {
718 - $existing = (string) get_post_meta( $booking_id, '_tt_stripe_payment_intent_id', true );
748 + $existing = (string) get_post_meta( $booking_id, '_tt_stripe_payment_finalized_intent_id', true );
719 749 if ( $existing !== $stored_intent_id ) {
720 750 return new WP_HTTP_Response(
721 751 [
722 752 'success' => 0,
@@ -751,9 +781,9 @@
751 781
752 782 if ( is_wp_error( $update ) ) {
753 783 // Roll back the claim so a retry can finalize cleanly.
754 784 if ( $finalizing ) {
755 - delete_post_meta( $booking_id, '_tt_stripe_payment_intent_id', $stored_intent_id );
785 + delete_post_meta( $booking_id, '_tt_stripe_payment_finalized_intent_id', $stored_intent_id );
756 786 }
757 787 return new WP_HTTP_Response(
758 788 [
759 789 'success' => 0,
@@ -764,9 +794,20 @@
764 794 409
765 795 );
766 796 }
767 797
768 - if ( $default_booking_status === $post_status ) {
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 ) {
769 810 // Rotate the security token so the same one cannot drive a second
770 811 // approval after this booking has finalized.
771 812 $booking->rotate_security_token();
772 813
@@ -894,9 +935,23 @@
894 935 } else {
895 936 $status = $default_status;
896 937 }
897 938 } else {
898 - $current_status = ( new Booking( $id ) )->get_status();
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();
899 954 if ( 'cancel' === $client_status ) {
900 955 $status = 'cancel';
901 956 } else {
902 957 $status = $current_status;
@@ -920,20 +975,28 @@
920 975 // Use the validated email from the security check
921 976 $email = $email_validation;
922 977 }
923 978
924 - $validate = $this->validate(
925 - $data, [
926 - 'first_name',
927 - 'email',
928 - 'payment_method',
929 - 'appointment',
930 - 'start_date',
931 - 'start_time',
932 - 'end_time',
933 - ]
934 - );
979 + $required_fields = [
980 + 'first_name',
981 + 'email',
982 + 'appointment',
983 + 'start_date',
984 + 'start_time',
985 + 'end_time',
986 + ];
935 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 +
936 999 if ( is_wp_error( $validate ) ) {
937 1000 $data = [
938 1001 'status_code' => 403,
939 1002 'success' => 0,
@@ -989,30 +1052,40 @@
989 1052 'phone' => $phone,
990 1053 ]
991 1054 );
992 1055
993 - // Update booking schedule.
1056 + // Update booking schedule. Release the slot the booking currently holds;
1057 + // the new one is taken further below.
994 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 + );
995 1069
996 1070 $entries = $booking_entry->find(
997 1071 [
998 1072 'staff_id' => $booking->get_staff_id(),
999 1073 'meeting_id' => $booking->get_appointment(),
1000 - 'date' => $booking->get_start_date(),
1001 - 'start' => $booking->get_start_time(),
1074 + 'date' => $old_datetime->format( 'Y-m-d' ),
1075 + 'start' => $old_datetime->format( 'h:i a' ),
1002 1076 ]
1003 -
1004 1077 );
1005 1078
1006 1079 if ( $entries ) {
1007 1080 $entry = $booking_entry->first();
1008 1081
1009 - if ( 'one-to-one' == strtolower( $meeting->get_type() ) ) {
1082 + if ( 'one-to-one' == strtolower( $old_meeting->get_type() ) ) {
1010 1083 $entry->delete();
1011 1084 } else {
1012 1085 $booked = intval( $entry->get_booked() ) - 1;
1013 1086 $booked_data = apply_filters( 'timetics_booking_update_schedule', $entry, ['booked' => $booked], $data, $booking );
1014 - $entry->update( $booked_data );
1087 + $entry->update( $this->normalize_schedule_update( $booked_data, $booked ) );
1015 1088 }
1016 1089 }
1017 1090 }
1018 1091
@@ -1041,9 +1114,9 @@
1041 1114 'date' => $date,
1042 1115 'end_date' => $end_date,
1043 1116 'start_time' => $start_time,
1044 1117 'end_time' => $end_time,
1045 - 'order_total' => $this->calculate_order_total( $data ),
1118 + 'order_total' => ( $id && ! $is_privileged ) ? $booking->get_total() : $this->calculate_order_total( $data ),
1046 1119 'post_status' => $status,
1047 1120 'location' => $location,
1048 1121 'location_type' => $location_type,
1049 1122 'timezone' => $timezone,
@@ -1049,12 +1122,23 @@
1049 1122 'timezone' => $timezone,
1050 1123 'cancel_reason' => $cancel_reason,
1051 1124 ];
1052 1125
1126 + if ( 'created' === $action && '' !== $payment_method ) {
1127 + $booking_props['payment_method'] = $payment_method;
1128 + }
1129 +
1130 + $old_meeting_timestamp = 0;
1131 +
1053 1132 if ( $id ) {
1054 1133 $old_start_date = $booking->get_start_date();
1055 1134 $old_start_time = $booking->get_start_time();
1056 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 );
1057 1141 }
1058 1142
1059 1143 if( 'created' == $action ){
1060 1144 $booking_props['security_token'] = $booking->generate_security_token();
@@ -1097,8 +1181,21 @@
1097 1181 /**
1098 1182 * Added temporary for leagacy sass. It will remove in future.
1099 1183 */
1100 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 );
1101 1198 } else {
1102 1199 // Check if the booking date/time was actually changed
1103 1200 $date_time_changed = (
1104 1201 $old_start_date !== $start_date ||
@@ -1108,8 +1205,15 @@
1108 1205
1109 1206 $booking->update_event();
1110 1207
1111 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 +
1112 1216 $is_email_to_reschedule_customer = timetics_get_option( 'booking_rescheduled_customer');
1113 1217 $is_email_to_reschedule_host = timetics_get_option( 'booking_rescheduled_host');
1114 1218
1115 1219 if ( $is_email_to_reschedule_host ) {
@@ -1121,9 +1225,30 @@
1121 1225 $update_event_customer_email = new Update_Event_Customer_Email( $booking );
1122 1226 $update_event_customer_email->send();
1123 1227 }
1124 1228
1125 - do_action( 'timetics_gln_hook', 'booking_rescheduled', Notification::get_hook_data( $booking ) );
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 );
1126 1251 }
1127 1252 }
1128 1253 }
1129 1254
@@ -1130,49 +1255,44 @@
1130 1255 // Convert booking time to staff/meeting time.
1131 1256 $date_time = timetics_convert_timezone( $start_date . ' ' . $start_time, $timezone, $meeting->get_timezone() );
1132 1257 $end_time = timetics_convert_timezone( $start_date . ' ' . $end_time, $timezone, $meeting->get_timezone() );
1133 1258
1134 - // Create booking schedule.
1135 - $entries = $booking_entry->find(
1136 - [
1137 - 'staff_id' => $staff->get_id(),
1138 - 'meeting_id' => $meeting->get_id(),
1139 - 'date' => $date_time->format( 'Y-m-d' ),
1140 - 'start' => $date_time->format( 'h:i a' ),
1141 - ]
1142 - );
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 + );
1143 1272
1144 - if ( $entries ) {
1145 - $entry = $booking_entry->first();
1273 + if ( $entries ) {
1274 + $entry = $booking_entry->first();
1146 1275
1147 - if ( 'cancel' === $status ) {
1148 - $booked = intval( $entry->get_booked() ) - 1;
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 ) );
1149 1280 } else {
1150 - $booked = intval( $entry->get_booked() ) + 1;
1151 - }
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 + ];
1152 1291
1153 - $booked_data = apply_filters( 'timetics_booking_update_schedule', $entry, ['booked' => $booked], $data, $booking );
1154 -
1155 - if ( 'cancel' === $status && 'one-to-one' == strtolower( $meeting->get_type() ) ) {
1156 - $entry->delete();
1157 - } else {
1158 - $entry->update( $booked_data );
1292 + $book_entry_data = apply_filters( 'timetics_booking_schedule', $book_entry_data, $data );
1293 + $booking_entry->create( $book_entry_data );
1159 1294 }
1160 -
1161 - } else {
1162 - $book_entry_data = [
1163 - 'meeting_id' => $meeting->get_id(),
1164 - 'staff_id' => $staff->get_id(),
1165 - 'customer_id' => $customer->get_id(),
1166 - 'booking_id' => $booking->get_id(),
1167 - 'booked' => 1,
1168 - 'date' => $date_time->format( 'Y-m-d' ),
1169 - 'start' => $date_time->format( 'h:i a' ),
1170 - 'end' => $end_time->format( 'h:i a' ),
1171 - ];
1172 -
1173 - $book_entry_data = apply_filters( 'timetics_booking_schedule', $book_entry_data, $data );
1174 - $booking_entry->create( $book_entry_data );
1175 1295 }
1176 1296
1177 1297 // For newly created bookings, create the calendar event now that the
1178 1298 // booking schedule entry exists. This generates the Google Meet link
@@ -1177,9 +1297,20 @@
1177 1297 // For newly created bookings, create the calendar event now that the
1178 1298 // booking schedule entry exists. This generates the Google Meet link
1179 1299 // (stored in booking meta) so it can be shown on the success page and
1180 1300 // included in the notification emails sent below.
1181 - if ( 'created' === $action && 'cancel' !== $status ) {
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 ) {
1182 1313 $booking->create_event();
1183 1314 }
1184 1315
1185 1316 // Send booking creation emails for new bookings not processed through
@@ -1224,9 +1355,9 @@
1224 1355 * @param integer $booking_id
1225 1356 *
1226 1357 * @return array
1227 1358 */
1228 - public function prepare_item( $booking_id ) {
1359 + public function prepare_item( $booking_id, $expose_token = true ) {
1229 1360 $booking = new Booking( $booking_id );
1230 1361 $appointment = new Appointment( $booking->get_appointment() );
1231 1362 $staff = new Staff( $booking->get_staff_id() );
1232 1363 $customer = new Customer( $booking->get_customer_id() );
@@ -1259,9 +1390,12 @@
1259 1390 'location' => $booking->get_location(),
1260 1391 'location_type' => $booking->get_location_type(),
1261 1392 'description' => $booking->get_description(),
1262 1393 'cancel_reason' => $booking->get_cancel_reason(),
1263 - 'security_token' => $booking->get_security_token(),
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() : '',
1264 1398 'payment_method' => $booking->get_payment_method(),
1265 1399 'payment_status' => $booking->get_payment_status(),
1266 1400 'payment_details' => $payment_details,
1267 1401 'customer' => [
@@ -1329,39 +1463,27 @@
1329 1463
1330 1464 return new WP_HTTP_Response( $data, 403 );
1331 1465 }
1332 1466
1333 - $booking_entry = new Booking_Entry();
1334 1467
1335 - $date_time = timetics_convert_timezone( $booking->get_start_date() . ' ' . $booking->get_start_time(), $booking->get_timezone(), $meeting->get_timezone() );
1468 + $booking->release_slot();
1336 1469
1337 - $entries = $booking_entry->find(
1338 - [
1339 - 'staff_id' => $booking->get_staff_id(),
1340 - 'meeting_id' => $booking->get_appointment(),
1341 - 'date' => $date_time->format( 'Y-m-d' ),
1342 - 'start' => $date_time->format( 'h:i a' ),
1343 - ]
1344 - );
1470 + $recurrences = $booking->get_recurrence();
1345 1471
1346 - if ( $entries ) {
1347 - $entry = $booking_entry->first();
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(), [] );
1348 1485
1349 - if ( 'one-to-one' == strtolower( $meeting->get_type() ) ) {
1350 - $entry->delete();
1351 - } else {
1352 - $booked = intval( $entry->get_booked() ) - 1;
1353 - $booked_seat = ! empty( $booking->get_seat() ) ? $booking->get_seat() : [];
1354 - $existing_seat = ! empty( $entry->get_seats() ) ? $entry->get_seats() : [];
1355 -
1356 - $entry->update( [
1357 - 'booked' => $booked,
1358 - 'seats' => array_values( array_diff( $existing_seat, $booked_seat ) ),
1359 - ] );
1360 - }
1361 - }
1362 -
1363 - $recurrences = $booking->get_recurrence();
1364 1486 $booking->delete_event();
1365 1487 $booking->delete();
1366 1488
1367 1489 $is_email_to_customer = timetics_get_option( 'booking_canceled_customer');
@@ -1411,12 +1533,43 @@
1411 1533 if ( $booked && intval( $booked->get_booked() ) >= $meeting->get_effective_capacity() ) {
1412 1534 return false;
1413 1535 }
1414 1536
1415 - return true;
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 );
1416 1551 }
1417 1552
1418 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 + /**
1419 1572 * Validates a booking.
1420 1573 *
1421 1574 * @param int $appointment_id The ID of the appointment.
1422 1575 * @param array $data The data for the booking.
@@ -1533,10 +1686,16 @@
1533 1686 if (empty($booking_id) || ! wp_verify_nonce($nonce, 'wp_rest')) {
1534 1687 return false;
1535 1688 }
1536 1689
1537 - // Allow booking owner or admins/managers.
1538 - if ( (int) $booking->get_customer_id() === get_current_user_id() || current_user_can( 'manage_timetics' )) {
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 + ) {
1539 1698 return true;
1540 1699 }
1541 1700
1542 1701 return false;
@@ -1565,11 +1724,20 @@
1565 1724 return true;
1566 1725 }
1567 1726 }
1568 1727
1569 - if (wp_verify_nonce($nonce, 'wp_rest') && current_user_can( 'manage_timetics' ) ) {
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 + ) {
1570 1737 return true;
1571 1738 }
1739 +
1572 1740 return false;
1573 1741 }
1574 1742
1575 1743 /**
@@ -1583,10 +1751,10 @@
1583 1751 *
1584 1752 * @return string|WP_Error Returns the validated email on success, WP_Error on failure.
1585 1753 */
1586 1754 private function validate_email_change_permission( $booking_id, $new_email ) {
1587 - // Admin users have full permission to change email addresses
1588 - if ( current_user_can( 'manage_timetics' ) ) {
1755 + // manage_timetics is not admin-only — every staff account holds it.
1756 + if ( timetics_can_view_all_data() ) {
1589 1757 return $new_email;
1590 1758 }
1591 1759
1592 1760 $existing_booking = new Booking( $booking_id );
@@ -1703,8 +1871,23 @@
1703 1871 409
1704 1872 );
1705 1873 }
1706 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 +
1707 1890 $result = $stripe->update_payment_intent(
1708 1891 $intent_id,
1709 1892 [
1710 1893 'booking_id' => $booking_id,
@@ -1722,8 +1905,12 @@
1722 1905 502
1723 1906 );
1724 1907 }
1725 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 +
1726 1913 return new WP_HTTP_Response(
1727 1914 [
1728 1915 'success' => 1,
1729 1916 'status_code' => 200,
@@ -1757,9 +1944,12 @@
1757 1944 // constant-time comparison
1758 1945 if ( ! hash_equals( $stored_token, $appointment_token ) ) {
1759 1946 return false;
1760 1947 }
1761 - if ( 'pending' !== (string) $booking->get_status() ) {
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 ) ) {
1762 1952 return false;
1763 1953 }
1764 1954
1765 1955 return true;