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/booking.php +269 -31 1.0.56 → 1.0.64 View file →
@@ -6,8 +6,10 @@
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 13 use Timetics\Core\Customers\Customer;
12 14 use Timetics\Core\Integrations\Google\Service\Calendar;
13 15 use Timetics\Core\Staffs\Staff;
@@ -355,8 +357,17 @@
355 357 return $this->get_prop( 'payment_method' );
356 358 }
357 359
358 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 + /**
359 370 * Get appointment
360 371 *
361 372 * @return Appointment
362 373 */
@@ -650,8 +661,190 @@
650 661 return $updated;
651 662 }
652 663
653 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 + /**
654 847 * Delete booking
655 848 *
656 849 * @return bool | WP_Error
657 850 */
@@ -765,37 +958,25 @@
765 958
766 959 }
767 960
768 961 public function create_appointment_event() {
769 - $data = [
770 - 'summary' => timetics_get_option( 'booking_created_customer_email_title' ),
771 - 'description' => timetics_get_option( 'booking_created_customer_email_body' ),
772 - ];
773 -
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.
774 967 $calendar = new Calendar();
775 - $event_data = $this->prepare_event( $data );
968 + $event_data = $this->prepare_event();
776 969
777 970 if ( ! $event_data ) {
778 971 return;
779 972 }
780 973
781 - $booking_entry = new Booking_Entry();
782 - $meeting = new Appointment( $this->get_appointment() );
974 + $entry = $this->find_slot_entry();
783 975
784 - $date_time = timetics_convert_timezone( $this->get_start_date() .' '. $this->get_start_time(), $this->get_timezone(), $meeting->get_timezone() );
976 + $event = false;
785 977
786 - $entries = $booking_entry->find(
787 - [
788 - 'staff_id' => $this->get_staff_id(),
789 - 'meeting_id' => $this->get_appointment(),
790 - 'date' => $date_time->format('Y-m-d'),
791 - 'start' => $date_time->format('h:i a'),
792 - ]
793 - );
794 -
795 - if ( $entries ) {
796 - $entry = $booking_entry->first();
797 -
978 + if ( $entry ) {
798 979 $event = $entry->get_google_event();
799 980
800 981 if ( ! $event ) {
801 982 $event = $calendar->create_event( $event_data );
@@ -806,8 +987,15 @@
806 987 }
807 988
808 989 if ( $event ) {
809 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 + }
810 998 }
811 999 }
812 1000
813 1001 /**
@@ -819,15 +1007,13 @@
819 1007 if ( ! timetics_google_setup() ) {
820 1008 return;
821 1009 }
822 1010
823 - $data = [
824 - 'summary' => timetics_get_option( 'booking_rescheduled_customer_email_title' ),
825 - 'description' => timetics_get_option( 'booking_rescheduled_customer_email_body' ),
826 - ];
827 -
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.
828 1014 $calendar = new Calendar();
829 - $event_data = $this->prepare_event( $data );
1015 + $event_data = $this->prepare_event();
830 1016 $calendar_event = $this->get_event();
831 1017
832 1018 if ( ! is_array( $calendar_event ) ) {
833 1019 return;
@@ -869,8 +1055,12 @@
869 1055 $event = $calendar->delete_event( $calendar_event['id'], $access_token );
870 1056
871 1057 // update calendar event data.
872 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( '' );
873 1063 }
874 1064 }
875 1065
876 1066 /**
@@ -1066,13 +1256,28 @@
1066 1256 * @param string $event_id
1067 1257 * @return bool
1068 1258 */
1069 1259 public function set_google_event_id( $event_id ) {
1070 - return $this->save_metadata( '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 );
1071 1270 }
1072 1271
1073 1272 public function set_sync_status( $status ) {
1074 - return $this->save_metadata( 'google_calendar_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 );
1075 1280 }
1076 1281
1077 1282 public function get_sync_status() {
1078 1283 return $this->get_metadata( 'google_calendar_sync_status' );
@@ -1083,9 +1288,9 @@
1083 1288 * @return array
1084 1289 */
1085 1290 public function get_all_google_event_ids() {
1086 1291 $meta_key = $this->meta_prefix . 'google_event_id';
1087 -
1292 +
1088 1293 $posts = get_posts(
1089 1294 array(
1090 1295 'post_type' => 'any',
1091 1296 'posts_per_page' => -1,
@@ -1124,9 +1329,9 @@
1124 1329 * @return string
1125 1330 */
1126 1331 public function get_security_token() {
1127 1332 return $this->get_prop( 'security_token' );
1128 - }
1333 + }
1129 1334
1130 1335 /**
1131 1336 * Generate and store a secure reschedule token for a booking
1132 1337 *
@@ -1135,6 +1340,39 @@
1135 1340 */
1136 1341 public function generate_security_token() {
1137 1342 $token = bin2hex(random_bytes(4)); // 8 hex chars
1138 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 ) );
1139 1377 }
1140 1378 }