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/hooks.php +316 -66 1.0.57 → 1.0.64 View file →
@@ -11,8 +11,9 @@
11 11
12 12 use Timetics\Core\Appointments\Appointment;
13 13 use Timetics\Core\Emails\Customer_Booking_Reminder_Email;
14 14 use Timetics\Core\Emails\Staff_Booking_Reminder_Email;
15 +use Timetics\Core\Integrations\Stripe\StripePayment;
15 16 use Timetics\Utils\Singleton;
16 17
17 18 /**
18 19 * Class Hooks
@@ -26,24 +27,58 @@
26 27 * @return void
27 28 */
28 29 public function init() {
29 30 add_action( 'timetics_after_booking_create', [$this, 'register_schedule'] );
30 - add_action( 'timetics_booking_remainder', [$this, 'send_reminder_email'], 10, 1 );
31 + add_action( 'timetics_booking_remainder', [$this, 'send_reminder_email'], 10, 2 );
31 32 add_action( 'timetics_booking_clear_schedule', [$this, 'clear_booking_schedule'] );
32 33
33 - add_action( 'timetics_after_booking_create', [$this, 'reschedule_booking'], 10, 4 );
34 + add_action( 'before_delete_post', [$this, 'release_slot_on_delete'] );
34 35
35 36 add_action( 'init', [$this, 'register_booking_status'] );
36 37 add_action( 'init', [$this, 'maybe_migrate_reminder_schedules'], 99 );
37 38
39 + // Covers sites active before this cron existed; no-op once scheduled.
40 + add_action( 'init', [$this, 'maybe_schedule_cleanup_cron'] );
41 +
38 42 add_action('woocommerce_before_calculate_totals', [ $this, 'timetics_variation_ticket_total_price' ] );
39 43
40 44 add_filter( 'woocommerce_add_cart_item_data', [ $this, 'timetics_add_cart_item_data' ], 10, 2 );
41 45
42 - add_action( 'admin_init', [$this, 'delete_booking_before_paid'] );
46 + add_filter( 'cron_schedules', [$this, 'register_cron_schedules'] );
47 +
48 + // Was admin_init-triggered, so unpaid bookings only got cleaned up when
49 + // someone loaded wp-admin. Now runs on a real WP-Cron schedule.
50 + add_action( 'timetics_cleanup_unpaid_bookings', [$this, 'delete_booking_before_paid'] );
43 51 }
44 52
45 53 /**
54 + * Add a 5-minute WP-Cron interval for the unpaid-booking cleanup sweep.
55 + *
56 + * @param array $schedules
57 + *
58 + * @return array
59 + */
60 + public function register_cron_schedules( $schedules ) {
61 + $schedules['timetics_five_minutes'] = [
62 + 'interval' => 5 * MINUTE_IN_SECONDS,
63 + 'display' => __( 'Every 5 Minutes (Timetics)', 'timetics' ),
64 + ];
65 +
66 + return $schedules;
67 + }
68 +
69 + /**
70 + * Schedule the unpaid-booking cleanup cron if it isn't already scheduled.
71 + *
72 + * @return void
73 + */
74 + public function maybe_schedule_cleanup_cron() {
75 + if ( ! wp_next_scheduled( 'timetics_cleanup_unpaid_bookings' ) ) {
76 + wp_schedule_event( time(), 'timetics_five_minutes', 'timetics_cleanup_unpaid_bookings' );
77 + }
78 + }
79 +
80 + /**
46 81 * Register cron job for schedule a reminder email
47 82 *
48 83 * @param integer $booking_id
49 84 *
@@ -49,15 +84,27 @@
49 84 *
50 85 * @return void
51 86 */
52 87 public function register_schedule( $booking_id ) {
88 + // Runs on update as well as create. Any reminder queued for the old
89 + // date/time is dropped first, otherwise the `wp_next_scheduled()` guard
90 + // below keeps the stale event and the new time is never scheduled.
91 + self::clear_reminders( $booking_id );
92 +
53 93 $booking = new Booking( $booking_id );
54 94
55 95 $date = $booking->get_start_date();
56 96 $time = $booking->get_start_time();
57 97
58 - $booking_timestamp = strtotime( $date . ' ' . $time );
98 + $booking_timezone = $booking->get_timezone();
59 99
100 + if ( ! $booking_timezone || ! timetics_is_valid_timezone( $booking_timezone ) ) {
101 + $booking_timezone = timetics_reminder_fallback_timezone();
102 + }
103 +
104 + $booking_datetime = new \DateTime( $date . ' ' . $time, new \DateTimeZone( $booking_timezone ) );
105 + $booking_timestamp = $booking_datetime->getTimestamp();
106 +
60 107 $reminder_time = timetics_get_option( 'remainder_time' );
61 108
62 109 if ( ! $reminder_time ) {
63 110 return;
@@ -62,29 +109,51 @@
62 109 if ( ! $reminder_time ) {
63 110 return;
64 111 }
65 112
66 - foreach ( $reminder_time as $time ) {
67 - $timestamp = null;
68 - $duration = $time['duration-time'];
113 + $queued = [];
69 114
70 - switch ( $time['custom_duration_type'] ) {
115 + foreach ( $reminder_time as $reminder ) {
116 + $offset = 0;
117 + $duration = isset( $reminder['duration-time'] ) ? intval( $reminder['duration-time'] ) : 0;
118 + $type = isset( $reminder['custom_duration_type'] ) ? $reminder['custom_duration_type'] : '';
119 +
120 + switch ( $type ) {
71 121 case 'min':
72 - $timestamp = $duration * 60;
122 + $offset = $duration * MINUTE_IN_SECONDS;
73 123 break;
74 124 case 'hour':
75 - $timestamp = $duration * 60 * 60;
125 + $offset = $duration * HOUR_IN_SECONDS;
76 126 break;
77 127 case 'day':
78 - $timestamp = ( $duration * 24 ) * 60 * 60;
128 + $offset = $duration * DAY_IN_SECONDS;
79 129 break;
80 130 }
81 131
82 - $timestamp = intval($booking_timestamp)- intval($timestamp);
132 + $reminder_timestamp = intval( $booking_timestamp ) - $offset;
83 133
84 - if ( ! wp_next_scheduled( 'timetics_booking_remainder', [$booking_id] ) ) {
85 - wp_schedule_single_event( $timestamp, 'timetics_booking_remainder', [$booking_id] );
134 + // Never schedule a reminder in the past. WP-Cron fires past-due
135 + // events on the next page load, which caused reminder emails to be
136 + // sent unexpectedly — and in bursts when a backlog flushed — even
137 + // though no new booking or action had occurred.
138 + if ( $reminder_timestamp <= time() ) {
139 + continue;
86 140 }
141 +
142 + // The same offset configured twice is one reminder, not two.
143 + if ( isset( $queued[ $offset ] ) ) {
144 + continue;
145 + }
146 +
147 + $queued[ $offset ] = true;
148 +
149 + // The offset travels in the cron args so every configured reminder
150 + // is a distinct event. Sharing one arg list made WP-Cron treat them
151 + // as the same hook: the old `wp_next_scheduled()` guard let only the
152 + // first list entry through, and even without it
153 + // wp_schedule_single_event() silently drops a duplicate falling
154 + // within 10 minutes of one already queued.
155 + wp_schedule_single_event( $reminder_timestamp, 'timetics_booking_remainder', [$booking_id, $offset] );
87 156 }
88 157 }
89 158
90 159 /**
@@ -90,12 +159,24 @@
90 159 /**
91 160 * Send booking reminder email
92 161 *
93 162 * @param integer $booking_id
163 + * @param integer $offset Seconds before the meeting this reminder was queued for.
164 + * Part of the cron args only so each configured reminder is
165 + * a distinct event; not used when composing the email.
94 166 *
95 167 * @return void
96 168 */
97 - public function send_reminder_email( $booking_id ) {
169 + public function send_reminder_email( $booking_id, $offset = 0 ) {
170 + // The cron event outlives the booking, so re-check it here: a booking
171 + // cancelled or deleted after the reminder was scheduled must not get a
172 + // reminder for a meeting that no longer exists.
173 + $status = get_post_status( $booking_id );
174 +
175 + if ( ! $status || in_array( $status, ['cancel', 'cancelled', 'failed', 'trash'], true ) ) {
176 + return;
177 + }
178 +
98 179 $booking_reminder_customer = timetics_get_option( 'booking_reminder_customer' );
99 180 $booking_reminder_host = timetics_get_option( 'booking_reminder_host' );
100 181
101 182 $booking = new Booking( $booking_id );
@@ -112,8 +193,67 @@
112 193
113 194 }
114 195
115 196 /**
197 + * Remove every reminder cron event queued for a booking.
198 + *
199 + * @param integer $booking_id
200 + *
201 + * @return integer Number of events removed.
202 + */
203 + public static function clear_reminders( $booking_id ) {
204 + $removed = 0;
205 +
206 + foreach ( self::find_reminders( $booking_id ) as $timestamp => $args ) {
207 + wp_unschedule_event( $timestamp, 'timetics_booking_remainder', $args );
208 + $removed++;
209 + }
210 +
211 + return $removed;
212 + }
213 +
214 + /**
215 + * Every reminder cron event queued for a booking, as timestamp => args.
216 + *
217 + * Walks the cron store rather than calling wp_next_scheduled() with a fixed
218 + * arg list: a booking has one event per configured reminder, each carrying
219 + * its own offset, so there is no single arg list to look up. Events queued
220 + * before the offset was added carry only [ booking_id ], so matching is on
221 + * the first argument to cover both shapes.
222 + *
223 + * @param integer $booking_id
224 + *
225 + * @return array
226 + */
227 + private static function find_reminders( $booking_id ) {
228 + $booking_id = (int) $booking_id;
229 + $cron = _get_cron_array();
230 + $found = [];
231 +
232 + if ( ! is_array( $cron ) ) {
233 + return $found;
234 + }
235 +
236 + foreach ( $cron as $timestamp => $hooks ) {
237 + if ( empty( $hooks['timetics_booking_remainder'] ) || ! is_array( $hooks['timetics_booking_remainder'] ) ) {
238 + continue;
239 + }
240 +
241 + foreach ( $hooks['timetics_booking_remainder'] as $event ) {
242 + $args = isset( $event['args'] ) ? (array) $event['args'] : [];
243 +
244 + if ( empty( $args ) || (int) $args[0] !== $booking_id ) {
245 + continue;
246 + }
247 +
248 + $found[ $timestamp ] = $args;
249 + }
250 + }
251 +
252 + return $found;
253 + }
254 +
255 + /**
116 256 * Clear cron job schedule
117 257 *
118 258 * @return
119 259 */
@@ -125,12 +265,15 @@
125 265 }
126 266
127 267 // Run cron action.
128 268 foreach ( $bookins['items'] as $booking ) {
129 - $timestamp = wp_next_scheduled( 'timetics_booking_remainder', [$booking->ID] );
130 -
131 - if ( $timestamp && $timestamp < time() ) {
132 - wp_unschedule_event( $timestamp, 'timetics_booking_remainder', [$booking->ID] );
269 + // Not wp_next_scheduled() with a fixed arg list: a booking now has one
270 + // event per configured reminder, each carrying its own offset, so a
271 + // single-arg lookup misses all of them.
272 + foreach ( self::find_reminders( $booking->ID ) as $timestamp => $args ) {
273 + if ( $timestamp < time() ) {
274 + wp_unschedule_event( $timestamp, 'timetics_booking_remainder', $args );
275 + }
133 276 }
134 277 }
135 278 }
136 279
@@ -198,13 +341,97 @@
198 341 _set_cron_array( $cron );
199 342 }
200 343
201 344 update_option( 'timetics_reminder_cron_migrated', $version, false );
345 +
346 + $this->maybe_reschedule_reminders( $version );
202 347 }
203 348
204 349 /**
350 + * Clear and re-schedule all booking reminder cron events with
351 + * corrected timezone-aware timestamps.
352 + *
353 + * Runs once per plugin version after the timezone fix.
354 + *
355 + * @param string $version
356 + *
357 + * @return void
358 + */
359 + private function maybe_reschedule_reminders( $version ) {
360 + $migration_key = 'timetics_reminder_tz_migrated';
361 +
362 + if ( get_option( $migration_key ) === $version ) {
363 + return;
364 + }
365 +
366 + $cron = _get_cron_array();
367 +
368 + if ( is_array( $cron ) ) {
369 + $changed = false;
370 +
371 + foreach ( $cron as $timestamp => $hooks ) {
372 + if ( ! is_array( $hooks ) ) {
373 + continue;
374 + }
375 +
376 + if ( isset( $hooks['timetics_booking_remainder'] ) ) {
377 + unset( $cron[ $timestamp ]['timetics_booking_remainder'] );
378 + $changed = true;
379 + }
380 +
381 + if ( empty( $cron[ $timestamp ] ) ) {
382 + unset( $cron[ $timestamp ] );
383 + }
384 + }
385 +
386 + if ( $changed ) {
387 + _set_cron_array( $cron );
388 + }
389 + }
390 +
391 + $all = Booking::all(
392 + [
393 + 'posts_per_page' => -1,
394 + 'post_status' => [ 'approved', 'pending' ],
395 + 'start_date' => gmdate( 'Y-m-d' ),
396 + ]
397 + );
398 +
399 + if ( ! empty( $all['items'] ) ) {
400 + foreach ( $all['items'] as $booking ) {
401 + $this->register_schedule( $booking->ID );
402 + }
403 + }
404 +
405 + update_option( $migration_key, $version, false );
406 + }
407 +
408 + /**
409 + * Give a booking's slot back when its post is permanently deleted.
410 + *
411 + * Only the REST controller released the entry; deletes from the posts
412 + * screen, WP-CLI or wp_delete_post() left it blocking the slot for good.
413 + * Hooked to permanent deletion, not trash, so a restore keeps its slot.
414 + *
415 + * @param integer $post_id
416 + *
417 + * @return void
418 + */
419 + public function release_slot_on_delete( $post_id ) {
420 + if ( 'timetics-booking' !== get_post_type( $post_id ) ) {
421 + return;
422 + }
423 +
424 + ( new Booking( $post_id ) )->release_slot();
425 + }
426 +
427 + /**
205 428 * Update bookked entry if reschedule
206 429 *
430 + * @deprecated 1.0.62 Ran after the booking already held its new time, so it
431 + * looked up the slot moved *into*, not the one left behind.
432 + * Use Booking::release_slot_at() with the previous slot.
433 + *
207 434 * @param integer $booking_id
208 435 * @param integer $customer_id
209 436 * @param integer $meeting_id
210 437 * @param array $data
@@ -298,9 +525,10 @@
298 525 }
299 526 }
300 527
301 528 /**
302 - * Delete bookings if unpaid before 30 mins
529 + * Delete bookings if unpaid before the configured expiry window
530 + * ('unpaid_booking_expiry_minutes' setting, default 5 mins)
303 531 *
304 532 * @return void
305 533 */
306 534 public function delete_booking_before_paid() {
@@ -305,8 +533,14 @@
305 533 */
306 534 public function delete_booking_before_paid() {
307 535 $args = [
308 536 'post_type' => 'timetics-booking',
537 + // Must be explicit: get_posts() defaults to 'publish', which
538 + // bookings never use (custom statuses only), so omitting this
539 + // matched nothing. Must NOT be 'any' either — a paid booking sits
540 + // at 'approved' (default_booking_status), not 'completed', so
541 + // restricting to pending/failed keeps paid bookings out for good.
542 + 'post_status' => [ 'pending', 'failed' ],
309 543 'numberposts' => -1,
310 544 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering bookings by payment method
311 545 'meta_query' => array(
312 546 'relation' => 'OR',
@@ -319,8 +553,14 @@
319 553 'key' => '_tt_booking_payment_method',
320 554 'value' => 'paypal',
321 555 'compare' => '=',
322 556 ),
557 + array(
558 + // Abandoned WooCommerce checkout — previously not covered.
559 + 'key' => '_tt_booking_payment_method',
560 + 'value' => 'woocommerce',
561 + 'compare' => '=',
562 + ),
323 563 ),
324 564 ];
325 565
326 566 $bookings = get_posts( $args );
@@ -327,9 +567,18 @@
327 567
328 568 foreach ( $bookings as $booking ) {
329 569 $booking = new Booking( $booking->ID );
330 570
331 - if ( 'completed' != $booking->get_status() && $this->is_booking_payment_expire( $booking ) ) {
571 + // Free ($0) bookings still get payment_method meta set from
572 + // whichever gateway is globally active, so they'd otherwise look
573 + // like an abandoned checkout. A free booking never needed payment
574 + // — skip regardless of that meta.
575 + if ( $booking->get_total() <= 0 ) {
576 + continue;
577 + }
578 +
579 + // Re-check status: may have changed since the query ran above.
580 + if ( in_array( $booking->get_status(), [ 'pending', 'failed' ], true ) && $this->is_booking_payment_expire( $booking ) ) {
332 581 $this->update_booking_entry( $booking->get_id() );
333 582 }
334 583 }
335 584 }
@@ -341,23 +590,27 @@
341 590 *
342 591 * @return bool
343 592 */
344 593 public function is_booking_payment_expire( $booking ) {
345 - // Booking date and time
594 + // post_date is site-local time (e.g. Asia/Dhaka), not UTC. Parsing it
595 + // with no timezone made PHP treat it as UTC already, pushing expiry
596 + // out by the site's UTC offset. post_date_gmt + explicit UTC fixes it.
346 597 $post = get_post( $booking->get_id() );
347 - $booking_datetime = $post->post_date;
598 + $booking_datetime = $post->post_date_gmt;
348 599
349 - // Convert the booking date and time to a DateTime object
350 - $booking_datetime_object = new \DateTime( $booking_datetime );
600 + $booking_datetime_object = new \DateTime( $booking_datetime, new \DateTimeZone( 'UTC' ) );
351 601
352 - // Calculate 30 minutes from the booking date and time
602 + // Admin-configurable via Settings > General; defaults to 5 minutes.
603 + // Clamped to >= 5: the cleanup cron itself only runs every 5 minutes,
604 + // so a lower value can't actually be honored, and 0/negative would
605 + // expire bookings instantly.
606 + $expiry_minutes = max( 5, (int) timetics_get_option( 'unpaid_booking_expiry_minutes', 5 ) );
353 607 $target_datetime = clone $booking_datetime_object;
354 - $target_datetime->modify( '+30 minutes' );
608 + $target_datetime->modify( "+{$expiry_minutes} minutes" );
355 609
356 - // Get the current date and time
357 - $current_datetime = new \DateTime();
610 + $current_datetime = new \DateTime( 'now', new \DateTimeZone( 'UTC' ) );
358 611
359 - // Check if 30 minutes have passed
612 + // Check if the expiry window has passed
360 613 if ( $current_datetime > $target_datetime ) {
361 614 return true;
362 615 }
363 616
@@ -372,58 +625,55 @@
372 625 * @return void
373 626 */
374 627 public function update_booking_entry( $booking_id ) {
375 628 $booking = new Booking( $booking_id );
376 - $meeting = new Appointment( $booking->get_appointment() );
377 629
378 630 if ( ! $booking->is_booking() ) {
379 631 return false;
380 632 }
381 633
382 - $current_user_id = get_current_user_id();
634 + // Stripe: a customer may still be completing checkout when this
635 + // expires. Cancel the PaymentIntent first so a late confirm can't
636 + // charge the card after we release the slot. If Stripe refuses
637 + // because it already succeeded, the money is real — leave the
638 + // booking pending instead of cancelling a paid customer.
639 + if ( 'stripe' === strtolower( (string) $booking->get_payment_method() ) ) {
640 + $intent_id = $booking->get_stripe_payment_intent_id();
383 641
384 - if (
385 - $meeting->is_appointment()
386 - && ! user_can( $current_user_id, 'manage_options' )
387 - && $meeting->get_author() != $current_user_id
388 - ) {
389 - $data = [
390 - 'success' => 0,
391 - 'message' => __( 'You are not allowed to delete this booking.', 'timetics' ),
392 - ];
642 + if ( '' !== $intent_id ) {
643 + $stripe = new StripePayment();
644 + $intent = $stripe->retrieve_payment_intent( $intent_id );
393 645
394 - return new \WP_HTTP_Response( $data, 403 );
646 + if ( is_array( $intent ) && isset( $intent['status'] ) && 'succeeded' === $intent['status'] ) {
647 + return false;
648 + }
649 +
650 + $stripe->cancel_payment_intent( $intent_id );
651 + }
395 652 }
396 653
397 - $booking_entry = new Booking_Entry();
654 + // No permission check: only caller is the WP-Cron sweep, which has no
655 + // current user (get_current_user_id() = 0) — the old manage_options
656 + // check silently blocked this on every cron run.
657 + //
658 + // release_slot() is idempotent (_tt_booking_slot_released flag), so a
659 + // slot already freed by a real payment is never double-released.
660 + $booking->release_slot();
398 661
399 - $date_time = timetics_convert_timezone( $booking->get_start_date() . ' ' . $booking->get_start_time(), $booking->get_timezone(), $meeting->get_timezone() );
662 + // PayPal still creates the calendar event before payment confirms
663 + // (see api-booking.php $is_awaiting_online_payment). delete_event()
664 + // no-ops if no event exists, so safe to call unconditionally.
665 + $booking->delete_event();
400 666
401 - $entries = $booking_entry->find(
667 + // Flip to 'cancel' so the admin list stops showing this as "Pending"
668 + // forever. update() directly, not the REST cancel action, so this
669 + // stays silent — no cancellation email, no automation hook.
670 + $booking->update(
402 671 [
403 - 'staff_id' => $booking->get_staff_id(),
404 - 'meeting_id' => $booking->get_appointment(),
405 - 'date' => $date_time->format( 'Y-m-d' ),
406 - 'start' => $date_time->format( 'h:i a' ),
672 + 'post_status' => 'cancel',
673 + 'cancel_reason' => __( 'Automatically cancelled — payment was not completed within the allowed time.', 'timetics' ),
407 674 ]
408 675 );
409 -
410 - if ( $entries ) {
411 - $entry = $booking_entry->first();
412 -
413 - if ( 'one-to-one' == strtolower( $meeting->get_type() ) ) {
414 - $entry->delete();
415 - } else {
416 - $booked = intval( $entry->get_booked() ) - 1;
417 - $booked_seat = ! empty( $booking->get_seat() ) ? $booking->get_seat() : [];
418 - $existing_seat = ! empty( $entry->get_seats() ) ? $entry->get_seats() : [];
419 -
420 - $entry->update( [
421 - 'booked' => $booked,
422 - 'seats' => array_values( array_diff( $existing_seat, $booked_seat ) ),
423 - ] );
424 - }
425 - }
426 676 }
427 677
428 678 /**
429 679 * Change price for cart item