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 +110 -30 1.0.62 → 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
@@ -34,16 +35,50 @@
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 *
@@ -490,9 +525,10 @@
490 525 }
491 526 }
492 527
493 528 /**
494 - * 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)
495 531 *
496 532 * @return void
497 533 */
498 534 public function delete_booking_before_paid() {
@@ -497,8 +533,14 @@
497 533 */
498 534 public function delete_booking_before_paid() {
499 535 $args = [
500 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' ],
501 543 'numberposts' => -1,
502 544 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering bookings by payment method
503 545 'meta_query' => array(
504 546 'relation' => 'OR',
@@ -511,8 +553,14 @@
511 553 'key' => '_tt_booking_payment_method',
512 554 'value' => 'paypal',
513 555 'compare' => '=',
514 556 ),
557 + array(
558 + // Abandoned WooCommerce checkout — previously not covered.
559 + 'key' => '_tt_booking_payment_method',
560 + 'value' => 'woocommerce',
561 + 'compare' => '=',
562 + ),
515 563 ),
516 564 ];
517 565
518 566 $bookings = get_posts( $args );
@@ -519,9 +567,18 @@
519 567
520 568 foreach ( $bookings as $booking ) {
521 569 $booking = new Booking( $booking->ID );
522 570
523 - 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 ) ) {
524 581 $this->update_booking_entry( $booking->get_id() );
525 582 }
526 583 }
527 584 }
@@ -533,23 +590,27 @@
533 590 *
534 591 * @return bool
535 592 */
536 593 public function is_booking_payment_expire( $booking ) {
537 - // 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.
538 597 $post = get_post( $booking->get_id() );
539 - $booking_datetime = $post->post_date;
598 + $booking_datetime = $post->post_date_gmt;
540 599
541 - // Convert the booking date and time to a DateTime object
542 - $booking_datetime_object = new \DateTime( $booking_datetime );
600 + $booking_datetime_object = new \DateTime( $booking_datetime, new \DateTimeZone( 'UTC' ) );
543 601
544 - // 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 ) );
545 607 $target_datetime = clone $booking_datetime_object;
546 - $target_datetime->modify( '+30 minutes' );
608 + $target_datetime->modify( "+{$expiry_minutes} minutes" );
547 609
548 - // Get the current date and time
549 - $current_datetime = new \DateTime();
610 + $current_datetime = new \DateTime( 'now', new \DateTimeZone( 'UTC' ) );
550 611
551 - // Check if 30 minutes have passed
612 + // Check if the expiry window has passed
552 613 if ( $current_datetime > $target_datetime ) {
553 614 return true;
554 615 }
555 616
@@ -564,36 +625,55 @@
564 625 * @return void
565 626 */
566 627 public function update_booking_entry( $booking_id ) {
567 628 $booking = new Booking( $booking_id );
568 - $meeting = new Appointment( $booking->get_appointment() );
569 629
570 630 if ( ! $booking->is_booking() ) {
571 631 return false;
572 632 }
573 633
574 - $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();
575 641
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 - ];
642 + if ( '' !== $intent_id ) {
643 + $stripe = new StripePayment();
644 + $intent = $stripe->retrieve_payment_intent( $intent_id );
585 645
586 - 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 + }
587 652 }
588 653
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. )
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.
595 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 + );
596 676 }
597 677
598 678 /**
599 679 * Change price for cart item