PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / 1.0.63
Timetics – Appointment Booking Calendar & Scheduling v1.0.63
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 / booking.php

booking.php in Timetics – Appointment Booking Calendar & Scheduling 1.0.63, at core/bookings/booking.php

1,379 lines 36.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Booking Class
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\Customers\Customer;
14 use Timetics\Core\Integrations\Google\Service\Calendar;
15 use Timetics\Core\Staffs\Staff;
16 use TimeticsEvent\Core\Event_Bookings\Event_Booking;
17 use TimeticsEvent\Core\Events\Event;
18 use WP_Query;
19
20 /**
21 * Class Booking
22 */
23 class Booking {
24 /**
25 * Store booking post type
26 *
27 * @var string
28 */
29 protected $pos_type = 'timetics-booking';
30
31 /**
32 * Store booking meta prefix
33 *
34 * @var string
35 */
36 protected $meta_prefix = '_tt_booking_';
37
38 /**
39 * Store booking id
40 *
41 * @var string
42 */
43 protected $id;
44
45 /**
46 * Store appointment object
47 *
48 * @var Object
49 */
50 protected $appointment;
51
52 /**
53 * Store appointment object
54 *
55 * @var Object
56 */
57 protected $event;
58
59 /**
60 * Store booking data
61 *
62 * @var array
63 */
64 protected $data = [
65
66 'post_status' => '',
67 'customer' => '',
68 'appointment' => '',
69 'appointment_name' => '',
70 'staff' => '',
71 'speaker' => '',
72 'event' => '',
73 'location' => '',
74 'description' => '',
75 'location_type' => '',
76 'payment_method' => '',
77 'payment_status' => '',
78 'payment_details' => '',
79 'order_total' => '',
80 'start_date' => '',
81 'end_date' => '',
82 'start_time' => '',
83 'end_time' => '',
84 'calendar_event' => '',
85 'random_id' => '',
86 'seats' => '',
87 'guests' => '',
88 'zoom' => '',
89 'custom_form_data' => '',
90 'timezone' => '',
91 'recurrence' => [],
92 'parent' => 0,
93 'cancel_reason' => '',
94 'custom_location_url' => '',
95 'security_token' => ''
96 ];
97
98 /**
99 * Booking Constructor
100 *
101 * @return void
102 */
103 public function __construct( $booking = 0 ) {
104 if ( $booking instanceof self ) {
105 $this->set_id( $booking->get_id() );
106 } elseif ( ! empty( $booking->ID ) ) {
107 $this->set_id( $booking->ID );
108 } elseif ( is_numeric( $booking ) && $booking > 0 ) {
109 $this->set_id( $booking );
110 }
111 }
112
113 /**
114 * Get id
115 *
116 * @return integer
117 */
118 public function get_id() {
119 return $this->id;
120 }
121
122 /**
123 * Get customer id
124 *
125 * @return integer
126 */
127 public function get_customer_id() {
128 return $this->get_prop( 'customer' );
129 }
130
131 /**
132 * Get attendee id
133 *
134 * @return integer
135 */
136 public function get_attendees() {
137 $attendees = $this->get_prop( 'attendees' );
138 return $attendees ? $attendees : [];
139 }
140
141 /**
142 * Get custom url
143 *
144 * @return string
145 */
146 public function get_custom_location_url() {
147 $custom_url = $this->get_prop( 'custom_location_url' );
148 return $custom_url ? $custom_url : '';
149 }
150
151 /**
152 * Get staff for the current booking
153 *
154 * @return integer
155 */
156 public function get_staff_id() {
157 return $this->get_prop( 'staff' );
158 }
159
160 /**
161 * Get speaker id
162 *
163 * @return integer
164 */
165 public function get_speaker_id() {
166 return $this->get_prop( 'speaker' );
167 }
168
169 /**
170 * Get booking type
171 *
172 * @return string
173 */
174 public function get_type() {
175 return $this->get_prop( 'type' );
176 }
177
178 /**
179 * Get event id
180 *
181 * @return integer
182 */
183 public function get_event_id() {
184 return $this->get_prop( 'event' );
185 }
186
187 /**
188 * Get customer
189 *
190 * @return User Object
191 */
192 public function get_customer() {
193 return get_userdata( $this->get_customer_id() );
194 }
195
196 /**
197 * Get seats of a booking
198 *
199 * @return array
200 */
201 public function get_seat() {
202 return $this->get_prop( 'seats' );
203 }
204
205 /**
206 * Get guests
207 *
208 * @return array
209 */
210 public function get_guests() {
211 return $this->get_prop( 'guests' );
212 }
213
214 /**
215 * Get custom fields
216 *
217 * @return array
218 */
219 public function get_custom_fields() {
220 return $this->get_prop( 'custom_form_data' );
221 }
222
223 /**
224 * Get billing first name
225 *
226 * @return string
227 */
228 public function get_billing_first_name() {
229 return $this->get_prop( 'first_name' );
230 }
231
232 /**
233 * Get location
234 *
235 * @return string
236 */
237 public function get_location() {
238 return $this->get_prop( 'location' );
239 }
240
241 /**
242 * Get location type
243 *
244 * @return string
245 */
246 public function get_location_type() {
247 return $this->get_prop( 'location_type' );
248 }
249
250 /**
251 * Get booking time
252 *
253 * @return string
254 */
255 public function get_booking_time() {
256 return $this->get_prop( 'booking_time' );
257 }
258
259 /**
260 * Get last name
261 *
262 * @return string
263 */
264 public function get_billing_last_name() {
265 return $this->get_prop( 'last_name' );
266 }
267
268 /**
269 * Get email
270 *
271 * @return string
272 */
273 public function get_billing_email() {
274 return $this->get_prop( 'email' );
275 }
276
277 /**
278 * Get billing phone
279 *
280 * @return string
281 */
282 public function get_billing_phone() {
283 return $this->get_prop( 'phone' );
284 }
285
286 /**
287 * Get timezone
288 *
289 * @return string
290 */
291 public function get_timezone() {
292 return $this->get_prop( 'timezone' );
293 }
294
295 /**
296 * Get description
297 *
298 * @return string
299 */
300 public function get_description() {
301 return $this->get_prop( 'description' );
302 }
303
304 /**
305 * Get billing address
306 *
307 * @param string $adress_type Booking billing address
308 *
309 * @return string
310 */
311 public function get_billing_address( $adress_type = 'address_1' ) {
312 return $this->get_prop( $adress_type );
313 }
314
315 /**
316 * Get billing city
317 *
318 * @return string
319 */
320 public function get_billing_city() {
321 return $this->get_prop( 'city' );
322 }
323
324 /**
325 * Get billing state
326 *
327 * @return string
328 */
329 public function get_billing_state() {
330 return $this->get_prop( 'state' );
331 }
332
333 /**
334 * Get billing post code
335 *
336 * @return string
337 */
338 public function get_billing_post_code() {
339 return $this->get_prop( 'post_code' );
340 }
341
342 /**
343 * Get billing
344 *
345 * @return string
346 */
347 public function get_billing_country() {
348 return $this->get_prop( 'country' );
349 }
350
351 /**
352 * Get payment method
353 *
354 * @return string
355 */
356 public function get_payment_method() {
357 return $this->get_prop( 'payment_method' );
358 }
359
360 /**
361 * Get payment status
362 *
363 * @return string
364 */
365 public function get_payment_status() {
366 return $this->get_prop( 'payment_status' );
367 }
368
369 /**
370 * Get appointment
371 *
372 * @return Appointment
373 */
374 public function get_appointment() {
375 return $this->get_prop( 'appointment' );
376 }
377
378 /**
379 * Get appointment name
380 *
381 * @return string
382 */
383 public function get_appointment_name() {
384 return $this->get_prop( 'appointment_name' );
385 }
386
387 /**
388 * Get calendar event
389 *
390 * @return array
391 */
392 public function get_event() {
393 return $this->get_prop( 'calendar_event' );
394 }
395
396 /**
397 * Get post status
398 *
399 * @return string
400 */
401 public function get_status() {
402 return get_post( $this->id )->post_status;
403 }
404
405 /**
406 * Get total
407 *
408 * @return integer
409 */
410 public function get_total() {
411 return $this->get_prop( 'order_total' );
412 }
413
414 /**
415 * Get start date
416 *
417 * @return string
418 */
419 public function get_start_date() {
420 return $this->get_prop( 'start_date' );
421 }
422 /**
423 * Get start date
424 *
425 * @return string
426 */
427 public function get_date() {
428 return $this->get_prop( 'date' );
429 }
430
431 /**
432 * Get end date
433 *
434 * @return string
435 */
436 public function get_end_date() {
437 return $this->get_prop( 'end_date' );
438 }
439
440 /**
441 * Get start time
442 *
443 * @return string
444 */
445 public function get_start_time() {
446 return $this->get_prop( 'start_time' );
447 }
448
449 /**
450 * Get end time
451 *
452 * @return string
453 */
454 public function get_end_time() {
455 return $this->get_prop( 'end_time' );
456 }
457
458 /**
459 * Get random id for the current booking
460 *
461 * @return string
462 */
463 public function get_random_id() {
464 return $this->get_prop( 'random_id' );
465 }
466
467 /**
468 * Get payment details
469 *
470 * @return array
471 */
472 public function get_payment_details() {
473 return $this->get_prop( 'payment_details' );
474 }
475
476 /**
477 * Get zoom meeting details
478 *
479 * @return array
480 */
481 public function get_zoom() {
482 return $this->get_prop( 'zoom' );
483 }
484
485 /**
486 * Get recurrences
487 *
488 * @return array
489 */
490 public function get_recurrence() {
491 return $this->get_prop( 'recurrence' );
492 }
493
494 /**
495 * Get parent
496 *
497 * @return integer
498 */
499 public function get_parent() {
500 return $this->get_prop( 'parent' );
501 }
502
503 /**
504 * Get booking cancel reason
505 *
506 * @return integer
507 */
508 public function get_cancel_reason() {
509 return $this->get_prop( 'cancel_reason' );
510 }
511
512 /**
513 * Get booking data
514 *
515 * @param string $prop
516 *
517 * @return mixed
518 */
519 private function get_prop( $prop = '' ) {
520 return $this->get_metadata( $prop );
521 }
522
523 /**
524 * Get metadata
525 *
526 * @param string $prop
527 *
528 * @return mixed
529 */
530 private function get_metadata( $prop = '' ) {
531 $meta_key = $this->meta_prefix . $prop;
532
533 return get_post_meta( $this->id, $meta_key, true );
534 }
535
536 /**
537 * Generate random id
538 *
539 * @return string
540 */
541 private function generate_randon_id() {
542 return chr( wp_rand( ord( 'A' ), ord( 'Z' ) ) ) . wp_rand( 575639, 575639 + 400 );
543 }
544
545 // Setter.
546
547 /**
548 * Set booking id
549 *
550 * @param integer $id
551 *
552 * @return void
553 */
554 public function set_id( $id ) {
555 $this->id = $id;
556 }
557
558 /**
559 * Set data
560 *
561 * @param array $args Booking Args
562 *
563 * @return void
564 */
565 public function set_props( $args = [] ) {
566 $this->data = wp_parse_args( $args, $this->data );
567 $this->appointment = new Appointment( $this->data['appointment'] );
568 if ( isset( $this->data['type'] ) && 'timetics-event' == $this->data['type'] ) {
569 $this->event = new Event( $this->data['event'] );
570 }
571
572 }
573
574 /**
575 * Save metada for booking
576 *
577 * @return void
578 */
579 public function save_metadata( $args = [] ) {
580 foreach ( $args as $key => $value ) {
581
582 // If a key not exists on data it will skip to save the data.
583 if ( ! array_key_exists( $key, $this->data ) ) {
584 continue;
585 }
586
587 // Prepare meta key.
588 $meta_key = $this->meta_prefix . $key;
589
590 if ( ! $value ) {
591 $value = $this->get_prop( $key );
592 }
593
594 // Update appointment meta data.
595 update_post_meta( $this->id, $meta_key, $value );
596 }
597 }
598
599 /**
600 * Save appointment
601 *
602 * @return void
603 */
604 public function save( $type = '' ) {
605 $args = [
606 'post_title' => $this->appointment->get_name(),
607 'post_type' => $this->pos_type,
608 'post_status' => $this->data['post_status'],
609 'post_author' => get_current_user_id(),
610 ];
611
612 if ( 'timetics-event' == $type ) {
613 $args = [
614 'post_title' => $this->event->get_name(),
615 'post_type' => $this->pos_type,
616 'post_status' => $this->data['post_status'],
617 'post_author' => get_current_user_id(),
618 ];
619 }
620
621 $updated = false;
622
623 if ( ! empty( $this->id ) ) {
624 $this->send_notification( 'update' );
625 $args['ID'] = $this->id;
626 $updated = true;
627 }
628
629 // Insert or Update appointment.
630 $booking_id = wp_insert_post( $args );
631 $this->data['random_id'] = $this->generate_randon_id();
632 $this->data = apply_filters( 'timetics/event/booking/all_data', $this->data);
633
634 if ( ! is_wp_error( $booking_id ) ) {
635 $this->set_id( $booking_id );
636 $this->save_metadata( $this->data );
637 }
638
639 if ( ! $updated ) {
640 $this->send_notification( 'create' );
641 }
642 }
643
644 /**
645 * Update booking
646 *
647 * @param array $args
648 *
649 * @return bool
650 */
651 public function update( $args = [] ) {
652 $post = get_post( $this->id )->to_array();
653 $args = wp_parse_args( $args, $post );
654
655 $updated = wp_update_post( $args );
656
657 if ( ! is_wp_error( $updated ) ) {
658 $this->save_metadata( $args );
659 }
660
661 return $updated;
662 }
663
664 /**
665 * Find the Booking_Entry (shared per-slot schedule record) this booking occupies.
666 *
667 * @param Appointment|null $meeting Optional pre-built meeting for this booking.
668 * @param string $start_date Slot date. Defaults to the booking's own.
669 * @param string $start_time Slot start. Defaults to the booking's own.
670 * @param string $timezone Timezone of the two above. Defaults to the booking's own.
671 * @return Booking_Entry|null The first matching entry, or null if none.
672 */
673 private function find_slot_entry( $meeting = null, $start_date = '', $start_time = '', $timezone = '' ) {
674 $meeting = $meeting ?: new Appointment( $this->get_appointment() );
675 $booking_entry = new Booking_Entry();
676
677 $start_date = $start_date ?: $this->get_start_date();
678 $start_time = $start_time ?: $this->get_start_time();
679 $timezone = $timezone ?: $this->get_timezone();
680
681 $date_time = timetics_convert_timezone(
682 $start_date . ' ' . $start_time,
683 $timezone,
684 $meeting->get_timezone()
685 );
686
687 $entries = $booking_entry->find(
688 [
689 'staff_id' => $this->get_staff_id(),
690 'meeting_id' => $this->get_appointment(),
691 'date' => $date_time->format( 'Y-m-d' ),
692 'start' => $date_time->format( 'h:i a' ),
693 ]
694 );
695
696 return $entries ? $booking_entry->first() : null;
697 }
698
699 /**
700 * Release the slot held by this booking.
701 *
702 * @return void
703 */
704 public function release_slot() {
705 if ( ! $this->is_booking() ) {
706 return;
707 }
708
709 // Idempotency guard: never release the same booking's slot twice.
710 if ( $this->get_prop( 'slot_released' ) ) {
711 return;
712 }
713
714 $this->release_slot_at( $this->get_start_date(), $this->get_start_time() );
715
716 update_post_meta( $this->id, $this->meta_prefix . 'slot_released', 1 );
717 }
718
719 /**
720 * Take back the slot this booking released.
721 *
722 * A declined payment marks the booking failed and releases its slot, so an
723 * attempt the customer walks away from does not block the time forever. When
724 * the customer retries on that same booking the slot has to be taken back
725 * before any money moves, otherwise the booking finalizes while the slot still
726 * reads as free and the next customer can book straight over it.
727 *
728 * @return bool True when the slot is held, false when it is no longer free.
729 */
730 public function reserve_slot() {
731 if ( ! $this->is_booking() ) {
732 return false;
733 }
734
735 // Nothing was released, so the slot is still held from booking creation.
736 if ( ! $this->get_prop( 'slot_released' ) ) {
737 return true;
738 }
739
740 $meeting = new Appointment( $this->get_appointment() );
741 $entry = $this->find_slot_entry( $meeting );
742 $seats = ! empty( $this->get_seat() ) ? (array) $this->get_seat() : [];
743 $taking = max( 1, count( $seats ) );
744
745 if ( $entry ) {
746 $booked = intval( $entry->get_booked() ) + $taking;
747
748 // Someone else took the time while this booking was in the failed state.
749 if ( $booked > $meeting->get_effective_capacity() ) {
750 return false;
751 }
752
753 $existing = ! empty( $entry->get_seats() ) ? (array) $entry->get_seats() : [];
754
755 $entry->update(
756 [
757 'booked' => $booked,
758 'seats' => array_values( array_unique( array_merge( $existing, $seats ) ) ),
759 ]
760 );
761 } else {
762 // A one-to-one release deletes the entry outright, so recreate it. Entries
763 // are stored in the meeting's timezone, the booking's in the customer's.
764 $start = timetics_convert_timezone( $this->get_start_date() . ' ' . $this->get_start_time(), $this->get_timezone(), $meeting->get_timezone() );
765 $end = timetics_convert_timezone( $this->get_start_date() . ' ' . $this->get_end_time(), $this->get_timezone(), $meeting->get_timezone() );
766
767 $booking_entry = new Booking_Entry();
768
769 $booking_entry->create(
770 [
771 'meeting_id' => $meeting->get_id(),
772 'staff_id' => $this->get_staff_id(),
773 'customer_id' => $this->get_customer(),
774 'booking_id' => $this->id,
775 'booked' => $taking,
776 'date' => $start->format( 'Y-m-d' ),
777 'start' => $start->format( 'h:i a' ),
778 'end' => $end->format( 'h:i a' ),
779 'seats' => $seats,
780 ]
781 );
782 }
783
784 delete_post_meta( $this->id, $this->meta_prefix . 'slot_released' );
785
786 return true;
787 }
788
789 /**
790 * Release the entry for one named slot of this booking.
791 *
792 * Rescheduling saves the new time first, so the old slot has to be named
793 * rather than read off the booking. No idempotency guard: the booking lives
794 * on and may release more slots as it moves.
795 *
796 * @param string $start_date Slot date, Y-m-d.
797 * @param string $start_time Slot start.
798 * @param string $timezone Timezone of the two above. Defaults to the booking's own.
799 *
800 * @return void
801 */
802 public function release_slot_at( $start_date, $start_time, $timezone = '' ) {
803 if ( ! $this->is_booking() ) {
804 return;
805 }
806
807 $meeting = new Appointment( $this->get_appointment() );
808 $entry = $this->find_slot_entry( $meeting, $start_date, $start_time, $timezone );
809
810 if ( ! $entry ) {
811 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
812 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug-only diagnostics for a missing booking entry.
813 error_log(
814 sprintf(
815 'Timetics release_slot: no Booking_Entry found for booking #%d ( staff %s, meeting %s, slot %s %s ).',
816 $this->id,
817 $this->get_staff_id(),
818 $this->get_appointment(),
819 $start_date,
820 $start_time
821 )
822 );
823 }
824
825 return;
826 }
827
828 if ( 'one-to-one' === strtolower( $meeting->get_type() ) ) {
829 $entry->delete();
830
831 return;
832 }
833
834 $booked = max( 0, intval( $entry->get_booked() ) - 1 );
835 $booked_seat = ! empty( $this->get_seat() ) ? $this->get_seat() : [];
836 $existing_seat = ! empty( $entry->get_seats() ) ? $entry->get_seats() : [];
837
838 $entry->update(
839 [
840 'booked' => $booked,
841 'seats' => array_values( array_diff( $existing_seat, $booked_seat ) ),
842 ]
843 );
844 }
845
846 /**
847 * Delete booking
848 *
849 * @return bool | WP_Error
850 */
851 public function delete() {
852 // Set action for sending notification.
853 $this->send_notification( 'delete' );
854
855 return wp_delete_post( $this->id, true );
856 }
857
858 /**
859 * Check is a booking or not
860 *
861 * @return bool
862 */
863 public function is_booking() {
864 $post = get_post( $this->id );
865
866 if ( $post && $this->pos_type === $post->post_type ) {
867 return true;
868 }
869
870 return false;
871 }
872
873 /**
874 * Get all bookings
875 *
876 * @param array $args Bookings args
877 *
878 * @return array
879 */
880 public static function all( $args = [] ) {
881
882 $status = [
883 'approved',
884 'pending',
885 'cancel',
886 'completed',
887 ];
888 $defaults = [
889 'post_type' => 'timetics-booking',
890 'posts_per_page' => 20,
891 'post_status' => 'any',
892 'paged' => 1,
893 ];
894
895 $args = wp_parse_args( $args, $defaults );
896
897 $args = apply_filters( 'timetics/admin/bookings/all', $args, $defaults );
898
899 $meta_query = ! empty( $args['meta_query'] ) && is_array( $args['meta_query'] ) ? $args['meta_query'] : [];
900
901 if ( ! empty( $args['start_date'] ) ) {
902 $meta_query[] = [
903 'key' => '_tt_booking_start_date',
904 'value' => $args['start_date'],
905 'compare' => '>=',
906 'type' => 'DATE',
907 ];
908 }
909
910 if ( ! empty( $args['meeting'] ) ) {
911 $meta_query[] = [
912 'key' => '_tt_booking_appointment',
913 'value' => $args['meeting'],
914 'compare' => '=',
915 ];
916 }
917
918 if ( ! empty( $args['staff'] ) ) {
919 $meta_query[] = [
920 'key' => '_tt_booking_staff',
921 'value' => $args['staff'],
922 'compare' => '=',
923 ];
924 }
925
926 if ( ! empty( $meta_query ) ) {
927 if ( count( $meta_query ) > 1 && empty( $meta_query['relation'] ) ) {
928 $meta_query['relation'] = 'AND';
929 }
930 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering bookings
931 $args['meta_query'] = $meta_query;
932 }
933
934 $post = new WP_Query( $args );
935
936 return [
937 'total' => $post->found_posts,
938 'items' => $post->posts,
939 ];
940 }
941
942 /**
943 * Create event after creating a booking
944 *
945 * @return bool
946 */
947 public function create_event() {
948
949 if ( ! timetics_google_setup() ) {
950 return;
951 }
952
953 if ('timetics-event' == $this->get_type()) {
954 Event_Booking::instance()->create_online_event($this->get_id());
955 }else {
956 $this->create_appointment_event();
957 }
958
959 }
960
961 public function create_appointment_event() {
962 // No summary/description passed, so prepare_event() falls back to the
963 // meeting's own name and description. The booking-created email subject
964 // and body used to be reused here, which put the notification copy
965 // ("New meeting scheduled!", greeting, date and time lines) into the
966 // calendar entry instead of the meeting's details.
967 $calendar = new Calendar();
968 $event_data = $this->prepare_event();
969
970 if ( ! $event_data ) {
971 return;
972 }
973
974 $entry = $this->find_slot_entry();
975
976 $event = false;
977
978 if ( $entry ) {
979 $event = $entry->get_google_event();
980
981 if ( ! $event ) {
982 $event = $calendar->create_event( $event_data );
983 $entry->update( [
984 'google_event' => $event,
985 ] );
986 }
987 }
988
989 if ( $event ) {
990 update_post_meta( $this->id, $this->meta_prefix . 'calendar_event', $event );
991
992 // Also record the bare event id. Google_Calendar_Sync uses this meta
993 // to recognise events Timetics itself created, so that they are not
994 // pulled back in as external events and used to block their own slot.
995 if ( ! empty( $event['id'] ) ) {
996 $this->set_google_event_id( $event['id'] );
997 }
998 }
999 }
1000
1001 /**
1002 * Update calendar event
1003 *
1004 * @return bool
1005 */
1006 public function update_event() {
1007 if ( ! timetics_google_setup() ) {
1008 return;
1009 }
1010
1011 // Same as create_appointment_event(): the meeting's name and description
1012 // belong in the calendar entry, not the reschedule email copy. Keeping
1013 // them consistent also stops a reschedule from rewriting the title.
1014 $calendar = new Calendar();
1015 $event_data = $this->prepare_event();
1016 $calendar_event = $this->get_event();
1017
1018 if ( ! is_array( $calendar_event ) ) {
1019 return;
1020 }
1021
1022 if ( ! empty( $calendar_event['id'] ) ) {
1023 // Update calendar event.
1024 $event = $calendar->update_event( $calendar_event['id'], $event_data );
1025
1026 // update calendar event data.
1027 update_post_meta( $this->id, $this->meta_prefix . 'calendar_event', $event );
1028 }
1029 }
1030
1031 /**
1032 * Delete calendar event
1033 *
1034 * @return
1035 */
1036 public function delete_event() {
1037 if ( ! timetics_google_setup() ) {
1038 return;
1039 }
1040
1041 $calendar = new Calendar();
1042 $calendar_event = $this->get_event();
1043 $access_token = timetics_get_google_access_token( $this->get_staff_id() );
1044
1045 if ( ! is_array( $calendar_event ) ) {
1046 return;
1047 }
1048
1049 if ( ! $access_token ) {
1050 return;
1051 }
1052
1053 if ( ! empty( $calendar_event['id'] ) ) {
1054 // Update calendar event.
1055 $event = $calendar->delete_event( $calendar_event['id'], $access_token );
1056
1057 // update calendar event data.
1058 update_post_meta( $this->id, $this->meta_prefix . 'calendar_event', $event );
1059
1060 // The event no longer exists in Google, so drop the id used by
1061 // Google_Calendar_Sync to skip Timetics-created events.
1062 $this->set_google_event_id( '' );
1063 }
1064 }
1065
1066 /**
1067 * Prepare event data
1068 *
1069 * @return array
1070 */
1071 private function prepare_event( $data = [] ) {
1072
1073 $meeting_id = $this->get_appointment();
1074 $staff_id = $this->get_staff_id();
1075 $customer_id = $this->get_customer_id();
1076 $location_type = $this->get_location_type();
1077 $access_token = timetics_get_google_access_token( $staff_id );
1078
1079 if ( ! $access_token ) {
1080 return false;
1081 }
1082
1083 $meeting = new Appointment( $meeting_id );
1084 $staff = new Staff( $staff_id );
1085 $customer = new Customer( $customer_id );
1086
1087 $time = gmdate( 'h:i a', strtotime( $this->get_start_time() ) );
1088 $date = gmdate( 'd F Y', strtotime( $this->get_start_date() ) );
1089 $location = $this->get_location();
1090
1091 $placeholders = [
1092 '{%meeting_title%}' => $meeting->get_name(),
1093 '{%meeting_date%}' => $date,
1094 '{%meeting_time%}' => $time,
1095 '{%meeting_location%}' => $location,
1096 '{%meeting_duration%}' => $meeting->get_duration(),
1097 '{%host_name%}' => $staff->get_display_name(),
1098 '{%host_email%}' => $staff->get_email(),
1099 '{%customer_name%}' => $customer->get_display_name(),
1100 '{%customer_email%}' => $customer->get_email(),
1101 ];
1102
1103 $summary = ! empty( $data['summary'] ) ? timetics_replace_placeholder( $data['summary'], $placeholders ) : $meeting->get_name();
1104 $description = ! empty( $data['description'] ) ? timetics_replace_placeholder( $data['description'], $placeholders ) : $meeting->get_description();
1105
1106 $description = apply_filters( 'timetics_booking_event_data', $description, $summary, $this );
1107
1108 $event = [
1109 'summary' => $summary,
1110 'description' => $description,
1111 'start' => [
1112 'date' => $this->get_start_date(),
1113 'time' => $this->get_start_time(),
1114 ],
1115 'end' => [
1116 'date' => $this->get_end_date(),
1117 'time' => $this->get_end_time(),
1118 ],
1119 'attendees' => [
1120 ['email' => $staff->get_email()],
1121 ['email' => $customer->get_email()],
1122 ],
1123 'timezone' => $this->get_timezone(),
1124 'google_meet' => 'google-meet' === $location_type,
1125 'access_token' => $access_token,
1126 ];
1127
1128 return $event;
1129 }
1130
1131 /**
1132 * Get all data of an object
1133 *
1134 * @return array
1135 */
1136 public function to_array() {
1137 $data = [];
1138
1139 foreach ( $this->data as $key => $value ) {
1140
1141 $data[$key] = $this->get_prop( $key );
1142 }
1143
1144 return $data;
1145 }
1146
1147 /**
1148 * Send notification after booking
1149 *
1150 * @return void
1151 */
1152 private function send_notification( $action ) {
1153
1154 do_action( 'timetics_booking_notification', $this, $action );
1155 }
1156
1157 /**
1158 * Get booking ids by appointment id
1159 *
1160 * @param array Collection of appointment ids
1161 *
1162 * @return array Collection of booking ids
1163 */
1164 public static function get_booking_ids_by_appointment( $appointment_ids, $per_page = -1 ) {
1165 if ( empty( $appointment_ids ) ) {
1166 return [];
1167 }
1168
1169 $query = new WP_Query([
1170 'post_type' => 'timetics-booking',
1171 'post_status' => 'any',
1172 'posts_per_page' => $per_page,
1173 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering bookings by appointment
1174 'meta_query' => [
1175 [
1176 'key' => '_tt_booking_appointment',
1177 'value' => $appointment_ids,
1178 'compare' => 'IN',
1179 ],
1180 ],
1181 ]);
1182
1183 return [
1184 'total' => $query->found_posts,
1185 'items' => $query->posts,
1186 ];
1187 }
1188
1189 /**
1190 * Get booking ids visible to a given user.
1191 *
1192 * Union of:
1193 * - bookings where the user is the assigned staff (`_tt_booking_staff`)
1194 * - bookings whose appointment is authored by the user
1195 *
1196 * @param integer $user_id WP user id.
1197 *
1198 * @return int[] Deduped list of booking post ids.
1199 */
1200 public static function get_visible_ids_for_user( $user_id ) {
1201 $user_id = (int) $user_id;
1202
1203 if ( ! $user_id ) {
1204 return [];
1205 }
1206
1207 $staff_ids = get_posts( [
1208 'post_type' => 'timetics-booking',
1209 'post_status' => 'any',
1210 'posts_per_page' => -1,
1211 'fields' => 'ids',
1212 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- needed to scope bookings to current staff user
1213 'meta_query' => [
1214 [
1215 'key' => '_tt_booking_staff',
1216 'value' => $user_id,
1217 ],
1218 ],
1219 ] );
1220
1221 $appointment_ids = Appointment::get_appointment_ids_by_author( $user_id );
1222
1223 $appointment_booking_ids = [];
1224 if ( ! empty( $appointment_ids ) ) {
1225 $appointment_booking_ids = get_posts( [
1226 'post_type' => 'timetics-booking',
1227 'post_status' => 'any',
1228 'posts_per_page' => -1,
1229 'fields' => 'ids',
1230 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- needed to scope bookings to appointments owned by current user
1231 'meta_query' => [
1232 [
1233 'key' => '_tt_booking_appointment',
1234 'value' => $appointment_ids,
1235 'compare' => 'IN',
1236 ],
1237 ],
1238 ] );
1239 }
1240
1241 return array_values( array_unique( array_map( 'intval', array_merge( $staff_ids, $appointment_booking_ids ) ) ) );
1242 }
1243
1244 /**
1245 * Get Google Calendar Event ID for this booking
1246 *
1247 * @return string
1248 */
1249 public function get_google_event_id() {
1250 return $this->get_metadata( 'google_event_id' );
1251 }
1252
1253 /**
1254 * Set Google Calendar Event ID for this booking
1255 *
1256 * @param string $event_id
1257 * @return bool
1258 */
1259 public function set_google_event_id( $event_id ) {
1260 // Written directly rather than through save_metadata(), which takes an
1261 // array and only accepts keys declared in $this->data — neither is true
1262 // here, so it silently discarded every write.
1263 $meta_key = $this->meta_prefix . 'google_event_id';
1264
1265 if ( ! $event_id ) {
1266 return delete_post_meta( $this->id, $meta_key );
1267 }
1268
1269 return update_post_meta( $this->id, $meta_key, $event_id );
1270 }
1271
1272 public function set_sync_status( $status ) {
1273 $meta_key = $this->meta_prefix . 'google_calendar_sync_status';
1274
1275 if ( ! $status ) {
1276 return delete_post_meta( $this->id, $meta_key );
1277 }
1278
1279 return update_post_meta( $this->id, $meta_key, $status );
1280 }
1281
1282 public function get_sync_status() {
1283 return $this->get_metadata( 'google_calendar_sync_status' );
1284 }
1285 /**
1286 * Get all Google Event IDs for all bookings
1287 *
1288 * @return array
1289 */
1290 public function get_all_google_event_ids() {
1291 $meta_key = $this->meta_prefix . 'google_event_id';
1292
1293 $posts = get_posts(
1294 array(
1295 'post_type' => 'any',
1296 'posts_per_page' => -1,
1297 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering posts by meta key
1298 'meta_query' => array(
1299 array(
1300 'key' => $meta_key,
1301 'value' => '',
1302 'compare' => '!=',
1303 ),
1304 ),
1305 'fields' => 'ids',
1306 )
1307 );
1308
1309 if ( empty( $posts ) ) {
1310 return array();
1311 }
1312
1313 $event_ids = array();
1314 foreach ( $posts as $post_id ) {
1315 $event_id = get_post_meta( $post_id, $meta_key, true );
1316 if ( ! empty( $event_id ) ) {
1317 $event_ids[] = $event_id;
1318 }
1319 }
1320
1321 return $event_ids;
1322 }
1323
1324 /**
1325 * Get name.
1326 *
1327 * @since 1.0.0
1328 *
1329 * @return string
1330 */
1331 public function get_security_token() {
1332 return $this->get_prop( 'security_token' );
1333 }
1334
1335 /**
1336 * Generate and store a secure reschedule token for a booking
1337 *
1338 * @param int $booking_id
1339 * @return string The generated token
1340 */
1341 public function generate_security_token() {
1342 $token = bin2hex(random_bytes(4)); // 8 hex chars
1343 return $token;
1344 }
1345
1346 /**
1347 * Rotate the booking's security token so the previously-issued one cannot
1348 * be reused (e.g. after a payment is approved).
1349 *
1350 * @return void
1351 */
1352 public function rotate_security_token() {
1353 $meta_key = $this->meta_prefix . 'security_token';
1354 $new_token = $this->generate_security_token();
1355 update_post_meta( $this->id, $meta_key, $new_token );
1356 }
1357
1358 /**
1359 * Get the Stripe PaymentIntent id bound to this booking, if any.
1360 *
1361 * @return string
1362 */
1363 public function get_stripe_payment_intent_id() {
1364 return (string) get_post_meta( $this->id, '_tt_stripe_payment_intent_id', true );
1365 }
1366
1367 /**
1368 * Bind a Stripe PaymentIntent id to this booking. Used for replay
1369 * protection: a second make_payment call with a different intent will be
1370 * rejected.
1371 *
1372 * @param string $intent_id
1373 * @return void
1374 */
1375 public function set_stripe_payment_intent_id( $intent_id ) {
1376 update_post_meta( $this->id, '_tt_stripe_payment_intent_id', sanitize_text_field( (string) $intent_id ) );
1377 }
1378 }
1379