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

hooks.php in Timetics – Appointment Booking Calendar & Scheduling 1.0.62, at core/bookings/hooks.php

646 lines 20.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Booking related hooks
4 *
5 * @package Timetics
6 */
7
8 namespace Timetics\Core\Bookings;
9
10 defined( 'ABSPATH' ) || exit;
11
12 use Timetics\Core\Appointments\Appointment;
13 use Timetics\Core\Emails\Customer_Booking_Reminder_Email;
14 use Timetics\Core\Emails\Staff_Booking_Reminder_Email;
15 use Timetics\Utils\Singleton;
16
17 /**
18 * Class Hooks
19 */
20 class Hooks {
21 use Singleton;
22
23 /**
24 * Initialization
25 *
26 * @return void
27 */
28 public function init() {
29 add_action( 'timetics_after_booking_create', [$this, 'register_schedule'] );
30 add_action( 'timetics_booking_remainder', [$this, 'send_reminder_email'], 10, 2 );
31 add_action( 'timetics_booking_clear_schedule', [$this, 'clear_booking_schedule'] );
32
33 add_action( 'before_delete_post', [$this, 'release_slot_on_delete'] );
34
35 add_action( 'init', [$this, 'register_booking_status'] );
36 add_action( 'init', [$this, 'maybe_migrate_reminder_schedules'], 99 );
37
38 add_action('woocommerce_before_calculate_totals', [ $this, 'timetics_variation_ticket_total_price' ] );
39
40 add_filter( 'woocommerce_add_cart_item_data', [ $this, 'timetics_add_cart_item_data' ], 10, 2 );
41
42 add_action( 'admin_init', [$this, 'delete_booking_before_paid'] );
43 }
44
45 /**
46 * Register cron job for schedule a reminder email
47 *
48 * @param integer $booking_id
49 *
50 * @return void
51 */
52 public function register_schedule( $booking_id ) {
53 // Runs on update as well as create. Any reminder queued for the old
54 // date/time is dropped first, otherwise the `wp_next_scheduled()` guard
55 // below keeps the stale event and the new time is never scheduled.
56 self::clear_reminders( $booking_id );
57
58 $booking = new Booking( $booking_id );
59
60 $date = $booking->get_start_date();
61 $time = $booking->get_start_time();
62
63 $booking_timezone = $booking->get_timezone();
64
65 if ( ! $booking_timezone || ! timetics_is_valid_timezone( $booking_timezone ) ) {
66 $booking_timezone = timetics_reminder_fallback_timezone();
67 }
68
69 $booking_datetime = new \DateTime( $date . ' ' . $time, new \DateTimeZone( $booking_timezone ) );
70 $booking_timestamp = $booking_datetime->getTimestamp();
71
72 $reminder_time = timetics_get_option( 'remainder_time' );
73
74 if ( ! $reminder_time ) {
75 return;
76 }
77
78 $queued = [];
79
80 foreach ( $reminder_time as $reminder ) {
81 $offset = 0;
82 $duration = isset( $reminder['duration-time'] ) ? intval( $reminder['duration-time'] ) : 0;
83 $type = isset( $reminder['custom_duration_type'] ) ? $reminder['custom_duration_type'] : '';
84
85 switch ( $type ) {
86 case 'min':
87 $offset = $duration * MINUTE_IN_SECONDS;
88 break;
89 case 'hour':
90 $offset = $duration * HOUR_IN_SECONDS;
91 break;
92 case 'day':
93 $offset = $duration * DAY_IN_SECONDS;
94 break;
95 }
96
97 $reminder_timestamp = intval( $booking_timestamp ) - $offset;
98
99 // Never schedule a reminder in the past. WP-Cron fires past-due
100 // events on the next page load, which caused reminder emails to be
101 // sent unexpectedly — and in bursts when a backlog flushed — even
102 // though no new booking or action had occurred.
103 if ( $reminder_timestamp <= time() ) {
104 continue;
105 }
106
107 // The same offset configured twice is one reminder, not two.
108 if ( isset( $queued[ $offset ] ) ) {
109 continue;
110 }
111
112 $queued[ $offset ] = true;
113
114 // The offset travels in the cron args so every configured reminder
115 // is a distinct event. Sharing one arg list made WP-Cron treat them
116 // as the same hook: the old `wp_next_scheduled()` guard let only the
117 // first list entry through, and even without it
118 // wp_schedule_single_event() silently drops a duplicate falling
119 // within 10 minutes of one already queued.
120 wp_schedule_single_event( $reminder_timestamp, 'timetics_booking_remainder', [$booking_id, $offset] );
121 }
122 }
123
124 /**
125 * Send booking reminder email
126 *
127 * @param integer $booking_id
128 * @param integer $offset Seconds before the meeting this reminder was queued for.
129 * Part of the cron args only so each configured reminder is
130 * a distinct event; not used when composing the email.
131 *
132 * @return void
133 */
134 public function send_reminder_email( $booking_id, $offset = 0 ) {
135 // The cron event outlives the booking, so re-check it here: a booking
136 // cancelled or deleted after the reminder was scheduled must not get a
137 // reminder for a meeting that no longer exists.
138 $status = get_post_status( $booking_id );
139
140 if ( ! $status || in_array( $status, ['cancel', 'cancelled', 'failed', 'trash'], true ) ) {
141 return;
142 }
143
144 $booking_reminder_customer = timetics_get_option( 'booking_reminder_customer' );
145 $booking_reminder_host = timetics_get_option( 'booking_reminder_host' );
146
147 $booking = new Booking( $booking_id );
148
149 if ( $booking_reminder_customer ) {
150 $customer_reminder = new Customer_Booking_Reminder_Email( $booking );
151 $customer_reminder->send();
152 }
153
154 if ( $booking_reminder_host ) {
155 $staff_reminder = new Staff_Booking_Reminder_Email( $booking );
156 $staff_reminder->send();
157 }
158
159 }
160
161 /**
162 * Remove every reminder cron event queued for a booking.
163 *
164 * @param integer $booking_id
165 *
166 * @return integer Number of events removed.
167 */
168 public static function clear_reminders( $booking_id ) {
169 $removed = 0;
170
171 foreach ( self::find_reminders( $booking_id ) as $timestamp => $args ) {
172 wp_unschedule_event( $timestamp, 'timetics_booking_remainder', $args );
173 $removed++;
174 }
175
176 return $removed;
177 }
178
179 /**
180 * Every reminder cron event queued for a booking, as timestamp => args.
181 *
182 * Walks the cron store rather than calling wp_next_scheduled() with a fixed
183 * arg list: a booking has one event per configured reminder, each carrying
184 * its own offset, so there is no single arg list to look up. Events queued
185 * before the offset was added carry only [ booking_id ], so matching is on
186 * the first argument to cover both shapes.
187 *
188 * @param integer $booking_id
189 *
190 * @return array
191 */
192 private static function find_reminders( $booking_id ) {
193 $booking_id = (int) $booking_id;
194 $cron = _get_cron_array();
195 $found = [];
196
197 if ( ! is_array( $cron ) ) {
198 return $found;
199 }
200
201 foreach ( $cron as $timestamp => $hooks ) {
202 if ( empty( $hooks['timetics_booking_remainder'] ) || ! is_array( $hooks['timetics_booking_remainder'] ) ) {
203 continue;
204 }
205
206 foreach ( $hooks['timetics_booking_remainder'] as $event ) {
207 $args = isset( $event['args'] ) ? (array) $event['args'] : [];
208
209 if ( empty( $args ) || (int) $args[0] !== $booking_id ) {
210 continue;
211 }
212
213 $found[ $timestamp ] = $args;
214 }
215 }
216
217 return $found;
218 }
219
220 /**
221 * Clear cron job schedule
222 *
223 * @return
224 */
225 public function clear_booking_schedule() {
226 $bookins = Booking::all();
227
228 if ( ! $bookins ) {
229 return;
230 }
231
232 // Run cron action.
233 foreach ( $bookins['items'] as $booking ) {
234 // Not wp_next_scheduled() with a fixed arg list: a booking now has one
235 // event per configured reminder, each carrying its own offset, so a
236 // single-arg lookup misses all of them.
237 foreach ( self::find_reminders( $booking->ID ) as $timestamp => $args ) {
238 if ( $timestamp < time() ) {
239 wp_unschedule_event( $timestamp, 'timetics_booking_remainder', $args );
240 }
241 }
242 }
243 }
244
245 /**
246 * Migrate any outstanding cron events scheduled with the legacy
247 * `timetics_booking_remainder_{id}` hook name to the unified
248 * `timetics_booking_remainder` hook with the booking id as an argument.
249 *
250 * Runs once per plugin version.
251 *
252 * @return void
253 */
254 public function maybe_migrate_reminder_schedules() {
255 $version = defined( 'TIMETICS_VERSION' ) ? TIMETICS_VERSION : '0';
256
257 if ( get_option( 'timetics_reminder_cron_migrated' ) === $version ) {
258 return;
259 }
260
261 $cron = _get_cron_array();
262
263 if ( ! is_array( $cron ) ) {
264 update_option( 'timetics_reminder_cron_migrated', $version, false );
265 return;
266 }
267
268 $changed = false;
269
270 foreach ( $cron as $timestamp => $hooks ) {
271 if ( ! is_array( $hooks ) ) {
272 continue;
273 }
274
275 foreach ( $hooks as $hook => $events ) {
276 if ( strpos( $hook, 'timetics_booking_remainder_' ) !== 0 ) {
277 continue;
278 }
279
280 $booking_id = (int) substr( $hook, strlen( 'timetics_booking_remainder_' ) );
281
282 if ( ! $booking_id ) {
283 unset( $cron[ $timestamp ][ $hook ] );
284 $changed = true;
285 continue;
286 }
287
288 $args = [$booking_id];
289 $key = md5( serialize( $args ) );
290
291 $cron[ $timestamp ]['timetics_booking_remainder'][ $key ] = [
292 'schedule' => false,
293 'args' => $args,
294 ];
295
296 unset( $cron[ $timestamp ][ $hook ] );
297 $changed = true;
298 }
299
300 if ( empty( $cron[ $timestamp ] ) ) {
301 unset( $cron[ $timestamp ] );
302 }
303 }
304
305 if ( $changed ) {
306 _set_cron_array( $cron );
307 }
308
309 update_option( 'timetics_reminder_cron_migrated', $version, false );
310
311 $this->maybe_reschedule_reminders( $version );
312 }
313
314 /**
315 * Clear and re-schedule all booking reminder cron events with
316 * corrected timezone-aware timestamps.
317 *
318 * Runs once per plugin version after the timezone fix.
319 *
320 * @param string $version
321 *
322 * @return void
323 */
324 private function maybe_reschedule_reminders( $version ) {
325 $migration_key = 'timetics_reminder_tz_migrated';
326
327 if ( get_option( $migration_key ) === $version ) {
328 return;
329 }
330
331 $cron = _get_cron_array();
332
333 if ( is_array( $cron ) ) {
334 $changed = false;
335
336 foreach ( $cron as $timestamp => $hooks ) {
337 if ( ! is_array( $hooks ) ) {
338 continue;
339 }
340
341 if ( isset( $hooks['timetics_booking_remainder'] ) ) {
342 unset( $cron[ $timestamp ]['timetics_booking_remainder'] );
343 $changed = true;
344 }
345
346 if ( empty( $cron[ $timestamp ] ) ) {
347 unset( $cron[ $timestamp ] );
348 }
349 }
350
351 if ( $changed ) {
352 _set_cron_array( $cron );
353 }
354 }
355
356 $all = Booking::all(
357 [
358 'posts_per_page' => -1,
359 'post_status' => [ 'approved', 'pending' ],
360 'start_date' => gmdate( 'Y-m-d' ),
361 ]
362 );
363
364 if ( ! empty( $all['items'] ) ) {
365 foreach ( $all['items'] as $booking ) {
366 $this->register_schedule( $booking->ID );
367 }
368 }
369
370 update_option( $migration_key, $version, false );
371 }
372
373 /**
374 * Give a booking's slot back when its post is permanently deleted.
375 *
376 * Only the REST controller released the entry; deletes from the posts
377 * screen, WP-CLI or wp_delete_post() left it blocking the slot for good.
378 * Hooked to permanent deletion, not trash, so a restore keeps its slot.
379 *
380 * @param integer $post_id
381 *
382 * @return void
383 */
384 public function release_slot_on_delete( $post_id ) {
385 if ( 'timetics-booking' !== get_post_type( $post_id ) ) {
386 return;
387 }
388
389 ( new Booking( $post_id ) )->release_slot();
390 }
391
392 /**
393 * Update bookked entry if reschedule
394 *
395 * @deprecated 1.0.62 Ran after the booking already held its new time, so it
396 * looked up the slot moved *into*, not the one left behind.
397 * Use Booking::release_slot_at() with the previous slot.
398 *
399 * @param integer $booking_id
400 * @param integer $customer_id
401 * @param integer $meeting_id
402 * @param array $data
403 * @param integer $booking_entry
404 *
405 * @return void
406 */
407 public function reschedule_booking( $booking_id, $customer_id, $meeting_id, $data ) {
408 $reschedule = ! empty( $data['reschedule'] ) ? $data['reschedule'] : false;
409 $booking = new Booking( $booking_id );
410 $meeting = new Appointment( $meeting_id );
411 $booking_entry = new Booking_Entry();
412
413 if ( ! $reschedule ) {
414 return;
415 }
416
417 $entries = $booking_entry->find(
418 [
419 'staff_id' => $booking->get_staff_id(),
420 'meeting_id' => $meeting->get_id(),
421 'date' => $booking->get_start_date(),
422 'start' => $booking->get_start_time(),
423 ]
424 );
425
426 if ( ! $entries ) {
427 return;
428 }
429
430 $entry = $booking_entry->first();
431 $booked_seat = ! empty( $booking->get_seat() ) ? $booking->get_seat() : [];
432 $existing_seat = ! empty( $entry->get_seats() ) ? $entry->get_seats() : [];
433
434 if ( 'one-to-one' === strtolower( $meeting->get_type() ) ) {
435 $entry->delete();
436 } else {
437 $booked = intval( $entry->get_booked() ) - 1;
438
439 $entry->update( [
440 'booked' => $booked,
441 'seats' => array_values( array_diff( $existing_seat, $booked_seat ) ),
442 ] );
443 }
444 }
445
446 /**
447 * Register booking statuses
448 *
449 * @return void
450 */
451 public function register_booking_status() {
452 // Define label_count translations for each status
453 $label_counts = array(
454 /* translators: %s: Number of approved bookings */
455 'approved' => _n_noop(
456 'Approved <span class="count">(%s)</span>',
457 'Approved <span class="count">(%s)</span>',
458 'timetics'
459 ),
460
461 /* translators: %s: Number of pending bookings */
462 'pending' => _n_noop(
463 'Pending <span class="count">(%s)</span>',
464 'Pending <span class="count">(%s)</span>',
465 'timetics'
466 ),
467 /* translators: %s: Number of cancelled bookings */
468 'cancel' => _n_noop(
469 'Cancelled <span class="count">(%s)</span>',
470 'Cancelled <span class="count">(%s)</span>',
471 'timetics'
472 ),
473 /* translators: %s: Number of completed bookings */
474 'completed' => _n_noop(
475 'Completed <span class="count">(%s)</span>',
476 'Completed <span class="count">(%s)</span>',
477 'timetics'
478 ),
479 );
480
481 // Register each status
482 foreach ( $label_counts as $status => $label_count ) {
483 register_post_status( $status, array(
484 'public' => true,
485 'exclude_from_search' => false,
486 'show_in_admin_all_list' => false,
487 'show_in_admin_status_list' => false,
488 'label_count' => $label_count,
489 ) );
490 }
491 }
492
493 /**
494 * Delete bookings if unpaid before 30 mins
495 *
496 * @return void
497 */
498 public function delete_booking_before_paid() {
499 $args = [
500 'post_type' => 'timetics-booking',
501 'numberposts' => -1,
502 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering bookings by payment method
503 'meta_query' => array(
504 'relation' => 'OR',
505 array(
506 'key' => '_tt_booking_payment_method',
507 'value' => 'stripe',
508 'compare' => '=',
509 ),
510 array(
511 'key' => '_tt_booking_payment_method',
512 'value' => 'paypal',
513 'compare' => '=',
514 ),
515 ),
516 ];
517
518 $bookings = get_posts( $args );
519
520 foreach ( $bookings as $booking ) {
521 $booking = new Booking( $booking->ID );
522
523 if ( 'completed' != $booking->get_status() && $this->is_booking_payment_expire( $booking ) ) {
524 $this->update_booking_entry( $booking->get_id() );
525 }
526 }
527 }
528
529 /**
530 * Check booking payment time expaire or not
531 *
532 * @param Object $booking
533 *
534 * @return bool
535 */
536 public function is_booking_payment_expire( $booking ) {
537 // Booking date and time
538 $post = get_post( $booking->get_id() );
539 $booking_datetime = $post->post_date;
540
541 // Convert the booking date and time to a DateTime object
542 $booking_datetime_object = new \DateTime( $booking_datetime );
543
544 // Calculate 30 minutes from the booking date and time
545 $target_datetime = clone $booking_datetime_object;
546 $target_datetime->modify( '+30 minutes' );
547
548 // Get the current date and time
549 $current_datetime = new \DateTime();
550
551 // Check if 30 minutes have passed
552 if ( $current_datetime > $target_datetime ) {
553 return true;
554 }
555
556 return false;
557 }
558
559 /**
560 * Update booking entry if payment time expire
561 *
562 * @param integer $booking_id
563 *
564 * @return void
565 */
566 public function update_booking_entry( $booking_id ) {
567 $booking = new Booking( $booking_id );
568 $meeting = new Appointment( $booking->get_appointment() );
569
570 if ( ! $booking->is_booking() ) {
571 return false;
572 }
573
574 $current_user_id = get_current_user_id();
575
576 if (
577 $meeting->is_appointment()
578 && ! user_can( $current_user_id, 'manage_options' )
579 && $meeting->get_author() != $current_user_id
580 ) {
581 $data = [
582 'success' => 0,
583 'message' => __( 'You are not allowed to delete this booking.', 'timetics' ),
584 ];
585
586 return new \WP_HTTP_Response( $data, 403 );
587 }
588
589 // Delegate to the booking so the shared _tt_booking_slot_released flag
590 // applies: a booking already freed by make_payment() / WooCommerce sync
591 // becomes a no-op here, so this cleanup can never decrement the counter a
592 // second time. ( This runs inline on every admin_init, not via wp-cron;
593 // the previous inline decrement here re-ran each time and could drive
594 // group-meeting counters negative. )
595 $booking->release_slot();
596 }
597
598 /**
599 * Change price for cart item
600 */
601 public function timetics_variation_ticket_total_price( $cart_object ) {
602 foreach ( $cart_object->cart_contents as $key => $value ) {
603 if ( ! empty( $value['booking_id'] ) && $value['booking_id'] !== 0 ) {
604 $order_total = !empty( $value['_timetics_variation_total_price'] ) ? $value['_timetics_variation_total_price'] : 0;
605
606 $value['data']->get_price();
607 $value['data']->set_price($order_total);
608 $value['data']->set_regular_price($order_total);
609 $value['data']->set_sale_price($order_total);
610
611 }
612 }
613
614 }
615
616 /**
617 * add booking_id as cart item data
618 *
619 * @param integer $booking_id
620 *
621 * @return void
622 */
623 public function timetics_add_cart_item_data( $cart_item_data ) {
624 $session_data = WC()->session->get( 'timetics_data' );
625 $booking_id = $session_data['booking_id'];
626 $booking = new Booking( $booking_id );
627 $total_price = floatval($booking->get_total()); // Ensure $total_price is a float
628
629 if ( is_array( $booking->get_seat() ) ) {
630 $total_quantity = count( $booking->get_seat() );
631 } else {
632 $total_quantity = 1;
633 }
634
635 if( ! empty( $booking_id ) && $total_price !== 0 ) {
636 $cart_item_data['_timetics_variation_total_quantity'] = $total_quantity;
637 $cart_item_data['booking_id'] = $booking_id;
638
639 // For balancing the cart item price
640 $cart_item_data['_timetics_variation_total_price'] = $total_price / $total_quantity;
641 }
642
643 return $cart_item_data;
644 }
645 }
646