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 +134 -31 1.0.61 → 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
@@ -29,21 +30,55 @@
29 30 add_action( 'timetics_after_booking_create', [$this, 'register_schedule'] );
30 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 *
@@ -370,10 +405,33 @@
370 405 update_option( $migration_key, $version, false );
371 406 }
372 407
373 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 + /**
374 428 * Update bookked entry if reschedule
375 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 + *
376 434 * @param integer $booking_id
377 435 * @param integer $customer_id
378 436 * @param integer $meeting_id
379 437 * @param array $data
@@ -467,9 +525,10 @@
467 525 }
468 526 }
469 527
470 528 /**
471 - * 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)
472 531 *
473 532 * @return void
474 533 */
475 534 public function delete_booking_before_paid() {
@@ -474,8 +533,14 @@
474 533 */
475 534 public function delete_booking_before_paid() {
476 535 $args = [
477 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' ],
478 543 'numberposts' => -1,
479 544 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering bookings by payment method
480 545 'meta_query' => array(
481 546 'relation' => 'OR',
@@ -488,8 +553,14 @@
488 553 'key' => '_tt_booking_payment_method',
489 554 'value' => 'paypal',
490 555 'compare' => '=',
491 556 ),
557 + array(
558 + // Abandoned WooCommerce checkout — previously not covered.
559 + 'key' => '_tt_booking_payment_method',
560 + 'value' => 'woocommerce',
561 + 'compare' => '=',
562 + ),
492 563 ),
493 564 ];
494 565
495 566 $bookings = get_posts( $args );
@@ -496,9 +567,18 @@
496 567
497 568 foreach ( $bookings as $booking ) {
498 569 $booking = new Booking( $booking->ID );
499 570
500 - 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 ) ) {
501 581 $this->update_booking_entry( $booking->get_id() );
502 582 }
503 583 }
504 584 }
@@ -510,23 +590,27 @@
510 590 *
511 591 * @return bool
512 592 */
513 593 public function is_booking_payment_expire( $booking ) {
514 - // 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.
515 597 $post = get_post( $booking->get_id() );
516 - $booking_datetime = $post->post_date;
598 + $booking_datetime = $post->post_date_gmt;
517 599
518 - // Convert the booking date and time to a DateTime object
519 - $booking_datetime_object = new \DateTime( $booking_datetime );
600 + $booking_datetime_object = new \DateTime( $booking_datetime, new \DateTimeZone( 'UTC' ) );
520 601
521 - // 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 ) );
522 607 $target_datetime = clone $booking_datetime_object;
523 - $target_datetime->modify( '+30 minutes' );
608 + $target_datetime->modify( "+{$expiry_minutes} minutes" );
524 609
525 - // Get the current date and time
526 - $current_datetime = new \DateTime();
610 + $current_datetime = new \DateTime( 'now', new \DateTimeZone( 'UTC' ) );
527 611
528 - // Check if 30 minutes have passed
612 + // Check if the expiry window has passed
529 613 if ( $current_datetime > $target_datetime ) {
530 614 return true;
531 615 }
532 616
@@ -541,36 +625,55 @@
541 625 * @return void
542 626 */
543 627 public function update_booking_entry( $booking_id ) {
544 628 $booking = new Booking( $booking_id );
545 - $meeting = new Appointment( $booking->get_appointment() );
546 629
547 630 if ( ! $booking->is_booking() ) {
548 631 return false;
549 632 }
550 633
551 - $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();
552 641
553 - if (
554 - $meeting->is_appointment()
555 - && ! user_can( $current_user_id, 'manage_options' )
556 - && $meeting->get_author() != $current_user_id
557 - ) {
558 - $data = [
559 - 'success' => 0,
560 - 'message' => __( 'You are not allowed to delete this booking.', 'timetics' ),
561 - ];
642 + if ( '' !== $intent_id ) {
643 + $stripe = new StripePayment();
644 + $intent = $stripe->retrieve_payment_intent( $intent_id );
562 645
563 - 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 + }
564 652 }
565 653
566 - // Delegate to the booking so the shared _tt_booking_slot_released flag
567 - // applies: a booking already freed by make_payment() / WooCommerce sync
568 - // becomes a no-op here, so this cleanup can never decrement the counter a
569 - // second time. ( This runs inline on every admin_init, not via wp-cron;
570 - // the previous inline decrement here re-ran each time and could drive
571 - // group-meeting counters negative. )
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.
572 660 $booking->release_slot();
661 +
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();
666 +
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(
671 + [
672 + 'post_status' => 'cancel',
673 + 'cancel_reason' => __( 'Automatically cancelled — payment was not completed within the allowed time.', 'timetics' ),
674 + ]
675 + );
573 676 }
574 677
575 678 /**
576 679 * Change price for cart item