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 +555 -34 1.0.10 → 1.0.64 View file →
@@ -6,10 +6,14 @@
6 6 */
7 7
8 8 namespace Timetics\Core\Bookings;
9 9
10 +defined( 'ABSPATH' ) || exit;
11 +
10 12 use Timetics\Core\Appointments\Appointment;
11 -use Timetics\Core\Emails\Booking_Reminder_Email;
13 +use Timetics\Core\Emails\Customer_Booking_Reminder_Email;
14 +use Timetics\Core\Emails\Staff_Booking_Reminder_Email;
15 +use Timetics\Core\Integrations\Stripe\StripePayment;
12 16 use Timetics\Utils\Singleton;
13 17
14 18 /**
15 19 * Class Hooks
@@ -23,17 +27,58 @@
23 27 * @return void
24 28 */
25 29 public function init() {
26 30 add_action( 'timetics_after_booking_create', [$this, 'register_schedule'] );
27 - add_action( 'init', [$this, 'run_booking_schedule'] );
31 + add_action( 'timetics_booking_remainder', [$this, 'send_reminder_email'], 10, 2 );
28 32 add_action( 'timetics_booking_clear_schedule', [$this, 'clear_booking_schedule'] );
29 33
30 - add_action( 'timetics_after_booking_create', [$this, 'reschedule_booking'], 10, 4 );
34 + add_action( 'before_delete_post', [$this, 'release_slot_on_delete'] );
31 35
32 36 add_action( 'init', [$this, 'register_booking_status'] );
37 + add_action( 'init', [$this, 'maybe_migrate_reminder_schedules'], 99 );
38 +
39 + // Covers sites active before this cron existed; no-op once scheduled.
40 + add_action( 'init', [$this, 'maybe_schedule_cleanup_cron'] );
41 +
42 + add_action('woocommerce_before_calculate_totals', [ $this, 'timetics_variation_ticket_total_price' ] );
43 +
44 + add_filter( 'woocommerce_add_cart_item_data', [ $this, 'timetics_add_cart_item_data' ], 10, 2 );
45 +
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'] );
33 51 }
34 52
35 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 + /**
36 81 * Register cron job for schedule a reminder email
37 82 *
38 83 * @param integer $booking_id
39 84 *
@@ -39,15 +84,27 @@
39 84 *
40 85 * @return void
41 86 */
42 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 +
43 93 $booking = new Booking( $booking_id );
44 94
45 95 $date = $booking->get_start_date();
46 96 $time = $booking->get_start_time();
47 97
48 - $booking_timestamp = strtotime( $date . ' ' . $time );
98 + $booking_timezone = $booking->get_timezone();
49 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 +
50 107 $reminder_time = timetics_get_option( 'remainder_time' );
51 108
52 109 if ( ! $reminder_time ) {
53 110 return;
@@ -52,66 +109,151 @@
52 109 if ( ! $reminder_time ) {
53 110 return;
54 111 }
55 112
56 - foreach ( $reminder_time as $time ) {
57 - $timestamp = '';
58 - $duration = $time['duration-time'];
113 + $queued = [];
59 114
60 - 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 ) {
61 121 case 'min':
62 - $timestamp = $duration * 60;
122 + $offset = $duration * MINUTE_IN_SECONDS;
63 123 break;
64 124 case 'hour':
65 - $timestamp = $duration * 60 * 60;
125 + $offset = $duration * HOUR_IN_SECONDS;
66 126 break;
67 127 case 'day':
68 - $timestamp = ( $duration * 24 ) * 60 * 60;
128 + $offset = $duration * DAY_IN_SECONDS;
69 129 break;
70 130 }
71 131
72 - $timestamp = $booking_timestamp - $timestamp;
132 + $reminder_timestamp = intval( $booking_timestamp ) - $offset;
73 133
74 - if ( ! wp_next_scheduled( 'timetics_booking_remainder_' . $booking_id ) ) {
75 - wp_schedule_single_event( $timestamp, 'timetics_booking_remainder_' . $booking_id, [$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;
76 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] );
77 156 }
78 157 }
79 158
80 159 /**
81 - * Booking schedule
160 + * Send booking reminder email
82 161 *
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.
166 + *
83 167 * @return void
84 168 */
85 - public function run_booking_schedule() {
86 - $bookins = Booking::all();
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 );
87 174
88 - if ( ! $bookins ) {
175 + if ( ! $status || in_array( $status, ['cancel', 'cancelled', 'failed', 'trash'], true ) ) {
89 176 return;
90 177 }
91 178
92 - // Run cron action.
93 - foreach ( $bookins['items'] as $booking ) {
94 - add_action( 'timetics_booking_remainder_' . $booking->ID, [$this, 'send_reminder_email'] );
179 + $booking_reminder_customer = timetics_get_option( 'booking_reminder_customer' );
180 + $booking_reminder_host = timetics_get_option( 'booking_reminder_host' );
181 +
182 + $booking = new Booking( $booking_id );
183 +
184 + if ( $booking_reminder_customer ) {
185 + $customer_reminder = new Customer_Booking_Reminder_Email( $booking );
186 + $customer_reminder->send();
95 187 }
188 +
189 + if ( $booking_reminder_host ) {
190 + $staff_reminder = new Staff_Booking_Reminder_Email( $booking );
191 + $staff_reminder->send();
192 + }
193 +
96 194 }
97 195
98 196 /**
99 - * Send booking reminder email
197 + * Remove every reminder cron event queued for a booking.
100 198 *
101 199 * @param integer $booking_id
102 200 *
103 - * @return void
201 + * @return integer Number of events removed.
104 202 */
105 - public function send_reminder_email( $booking_id ) {
106 - $booking = new Booking( $booking_id );
203 + public static function clear_reminders( $booking_id ) {
204 + $removed = 0;
107 205
108 - $booking_reminder = new Booking_Reminder_Email( $booking );
206 + foreach ( self::find_reminders( $booking_id ) as $timestamp => $args ) {
207 + wp_unschedule_event( $timestamp, 'timetics_booking_remainder', $args );
208 + $removed++;
209 + }
109 210
110 - $booking_reminder->send();
211 + return $removed;
111 212 }
112 213
113 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 + /**
114 256 * Clear cron job schedule
115 257 *
116 258 * @return
117 259 */
@@ -123,19 +265,173 @@
123 265 }
124 266
125 267 // Run cron action.
126 268 foreach ( $bookins['items'] as $booking ) {
127 - $timestamp = wp_next_scheduled( '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 + }
276 + }
277 + }
278 + }
128 279
129 - if ( $timestamp && $timestamp < time() ) {
130 - wp_unschedule_event( $timestamp, 'timetics_booking_remainder_' . $booking->ID );
280 + /**
281 + * Migrate any outstanding cron events scheduled with the legacy
282 + * `timetics_booking_remainder_{id}` hook name to the unified
283 + * `timetics_booking_remainder` hook with the booking id as an argument.
284 + *
285 + * Runs once per plugin version.
286 + *
287 + * @return void
288 + */
289 + public function maybe_migrate_reminder_schedules() {
290 + $version = defined( 'TIMETICS_VERSION' ) ? TIMETICS_VERSION : '0';
291 +
292 + if ( get_option( 'timetics_reminder_cron_migrated' ) === $version ) {
293 + return;
294 + }
295 +
296 + $cron = _get_cron_array();
297 +
298 + if ( ! is_array( $cron ) ) {
299 + update_option( 'timetics_reminder_cron_migrated', $version, false );
300 + return;
301 + }
302 +
303 + $changed = false;
304 +
305 + foreach ( $cron as $timestamp => $hooks ) {
306 + if ( ! is_array( $hooks ) ) {
307 + continue;
131 308 }
309 +
310 + foreach ( $hooks as $hook => $events ) {
311 + if ( strpos( $hook, 'timetics_booking_remainder_' ) !== 0 ) {
312 + continue;
313 + }
314 +
315 + $booking_id = (int) substr( $hook, strlen( 'timetics_booking_remainder_' ) );
316 +
317 + if ( ! $booking_id ) {
318 + unset( $cron[ $timestamp ][ $hook ] );
319 + $changed = true;
320 + continue;
321 + }
322 +
323 + $args = [$booking_id];
324 + $key = md5( serialize( $args ) );
325 +
326 + $cron[ $timestamp ]['timetics_booking_remainder'][ $key ] = [
327 + 'schedule' => false,
328 + 'args' => $args,
329 + ];
330 +
331 + unset( $cron[ $timestamp ][ $hook ] );
332 + $changed = true;
333 + }
334 +
335 + if ( empty( $cron[ $timestamp ] ) ) {
336 + unset( $cron[ $timestamp ] );
337 + }
132 338 }
339 +
340 + if ( $changed ) {
341 + _set_cron_array( $cron );
342 + }
343 +
344 + update_option( 'timetics_reminder_cron_migrated', $version, false );
345 +
346 + $this->maybe_reschedule_reminders( $version );
133 347 }
134 348
135 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 + /**
136 428 * Update bookked entry if reschedule
137 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 + *
138 434 * @param integer $booking_id
139 435 * @param integer $customer_id
140 436 * @param integer $meeting_id
141 437 * @param array $data
@@ -187,18 +483,243 @@
187 483 *
188 484 * @return void
189 485 */
190 486 public function register_booking_status() {
191 - $statuses = timetics_get_post_status();
487 + // Define label_count translations for each status
488 + $label_counts = array(
489 + /* translators: %s: Number of approved bookings */
490 + 'approved' => _n_noop(
491 + 'Approved <span class="count">(%s)</span>',
492 + 'Approved <span class="count">(%s)</span>',
493 + 'timetics'
494 + ),
495 +
496 + /* translators: %s: Number of pending bookings */
497 + 'pending' => _n_noop(
498 + 'Pending <span class="count">(%s)</span>',
499 + 'Pending <span class="count">(%s)</span>',
500 + 'timetics'
501 + ),
502 + /* translators: %s: Number of cancelled bookings */
503 + 'cancel' => _n_noop(
504 + 'Cancelled <span class="count">(%s)</span>',
505 + 'Cancelled <span class="count">(%s)</span>',
506 + 'timetics'
507 + ),
508 + /* translators: %s: Number of completed bookings */
509 + 'completed' => _n_noop(
510 + 'Completed <span class="count">(%s)</span>',
511 + 'Completed <span class="count">(%s)</span>',
512 + 'timetics'
513 + ),
514 + );
192 515
193 - foreach ( $statuses as $status ) {
194 -
516 + // Register each status
517 + foreach ( $label_counts as $status => $label_count ) {
195 518 register_post_status( $status, array(
196 519 'public' => true,
197 520 'exclude_from_search' => false,
198 521 'show_in_admin_all_list' => false,
199 522 'show_in_admin_status_list' => false,
200 - 'label_count' => _n_noop( "$status (%s)", "$status (%s)", 'timetics' ),
523 + 'label_count' => $label_count,
201 524 ) );
202 525 }
526 + }
527 +
528 + /**
529 + * Delete bookings if unpaid before the configured expiry window
530 + * ('unpaid_booking_expiry_minutes' setting, default 5 mins)
531 + *
532 + * @return void
533 + */
534 + public function delete_booking_before_paid() {
535 + $args = [
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' ],
543 + 'numberposts' => -1,
544 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering bookings by payment method
545 + 'meta_query' => array(
546 + 'relation' => 'OR',
547 + array(
548 + 'key' => '_tt_booking_payment_method',
549 + 'value' => 'stripe',
550 + 'compare' => '=',
551 + ),
552 + array(
553 + 'key' => '_tt_booking_payment_method',
554 + 'value' => 'paypal',
555 + 'compare' => '=',
556 + ),
557 + array(
558 + // Abandoned WooCommerce checkout — previously not covered.
559 + 'key' => '_tt_booking_payment_method',
560 + 'value' => 'woocommerce',
561 + 'compare' => '=',
562 + ),
563 + ),
564 + ];
565 +
566 + $bookings = get_posts( $args );
567 +
568 + foreach ( $bookings as $booking ) {
569 + $booking = new Booking( $booking->ID );
570 +
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 ) ) {
581 + $this->update_booking_entry( $booking->get_id() );
582 + }
583 + }
584 + }
585 +
586 + /**
587 + * Check booking payment time expaire or not
588 + *
589 + * @param Object $booking
590 + *
591 + * @return bool
592 + */
593 + public function is_booking_payment_expire( $booking ) {
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.
597 + $post = get_post( $booking->get_id() );
598 + $booking_datetime = $post->post_date_gmt;
599 +
600 + $booking_datetime_object = new \DateTime( $booking_datetime, new \DateTimeZone( 'UTC' ) );
601 +
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 ) );
607 + $target_datetime = clone $booking_datetime_object;
608 + $target_datetime->modify( "+{$expiry_minutes} minutes" );
609 +
610 + $current_datetime = new \DateTime( 'now', new \DateTimeZone( 'UTC' ) );
611 +
612 + // Check if the expiry window has passed
613 + if ( $current_datetime > $target_datetime ) {
614 + return true;
615 + }
616 +
617 + return false;
618 + }
619 +
620 + /**
621 + * Update booking entry if payment time expire
622 + *
623 + * @param integer $booking_id
624 + *
625 + * @return void
626 + */
627 + public function update_booking_entry( $booking_id ) {
628 + $booking = new Booking( $booking_id );
629 +
630 + if ( ! $booking->is_booking() ) {
631 + return false;
632 + }
633 +
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();
641 +
642 + if ( '' !== $intent_id ) {
643 + $stripe = new StripePayment();
644 + $intent = $stripe->retrieve_payment_intent( $intent_id );
645 +
646 + if ( is_array( $intent ) && isset( $intent['status'] ) && 'succeeded' === $intent['status'] ) {
647 + return false;
648 + }
649 +
650 + $stripe->cancel_payment_intent( $intent_id );
651 + }
652 + }
653 +
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();
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 + );
676 + }
677 +
678 + /**
679 + * Change price for cart item
680 + */
681 + public function timetics_variation_ticket_total_price( $cart_object ) {
682 + foreach ( $cart_object->cart_contents as $key => $value ) {
683 + if ( ! empty( $value['booking_id'] ) && $value['booking_id'] !== 0 ) {
684 + $order_total = !empty( $value['_timetics_variation_total_price'] ) ? $value['_timetics_variation_total_price'] : 0;
685 +
686 + $value['data']->get_price();
687 + $value['data']->set_price($order_total);
688 + $value['data']->set_regular_price($order_total);
689 + $value['data']->set_sale_price($order_total);
690 +
691 + }
692 + }
693 +
694 + }
695 +
696 + /**
697 + * add booking_id as cart item data
698 + *
699 + * @param integer $booking_id
700 + *
701 + * @return void
702 + */
703 + public function timetics_add_cart_item_data( $cart_item_data ) {
704 + $session_data = WC()->session->get( 'timetics_data' );
705 + $booking_id = $session_data['booking_id'];
706 + $booking = new Booking( $booking_id );
707 + $total_price = floatval($booking->get_total()); // Ensure $total_price is a float
708 +
709 + if ( is_array( $booking->get_seat() ) ) {
710 + $total_quantity = count( $booking->get_seat() );
711 + } else {
712 + $total_quantity = 1;
713 + }
714 +
715 + if( ! empty( $booking_id ) && $total_price !== 0 ) {
716 + $cart_item_data['_timetics_variation_total_quantity'] = $total_quantity;
717 + $cart_item_data['booking_id'] = $booking_id;
718 +
719 + // For balancing the cart item price
720 + $cart_item_data['_timetics_variation_total_price'] = $total_price / $total_quantity;
721 + }
722 +
723 + return $cart_item_data;
203 724 }
204 725 }