# timetics/1.0.63/core/bookings/booking.php

Timetics – Appointment Booking Calendar &amp; Scheduling, version 1.0.63. 1,379 lines.

- Page: https://pluginprobe.com/plugins/timetics/1.0.63/code/core/bookings/booking.php
- Raw: https://pluginprobe.com/plugins/timetics/1.0.63/raw/core/bookings/booking.php
- Modified: 2026-09-21T08:26:24+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/timetics/1.0.63/code/core/bookings/booking.php#L10-L20`.

```php
<?php
/**
 * Booking Class
 *
 * @package Timetics
 */

namespace Timetics\Core\Bookings;

defined( 'ABSPATH' ) || exit;

use Timetics\Core\Appointments\Appointment;
use Timetics\Core\Customers\Customer;
use Timetics\Core\Integrations\Google\Service\Calendar;
use Timetics\Core\Staffs\Staff;
use TimeticsEvent\Core\Event_Bookings\Event_Booking;
use TimeticsEvent\Core\Events\Event;
use WP_Query;

/**
 * Class Booking
 */
class Booking {
    /**
     * Store booking post type
     *
     * @var string
     */
    protected $pos_type = 'timetics-booking';

    /**
     * Store booking meta prefix
     *
     * @var string
     */
    protected $meta_prefix = '_tt_booking_';

    /**
     * Store booking id
     *
     * @var string
     */
    protected $id;

    /**
     * Store appointment object
     *
     * @var Object
     */
    protected $appointment;

    /**
     * Store appointment object
     *
     * @var Object
     */
    protected $event;

    /**
     * Store booking data
     *
     * @var array
     */
    protected $data = [

        'post_status'           => '',
        'customer'              => '',
        'appointment'           => '',
        'appointment_name'      => '',
        'staff'                 => '',
        'speaker'               => '',
        'event'                 => '',
        'location'              => '',
        'description'           => '',
        'location_type'         => '',
        'payment_method'        => '',
        'payment_status'        => '',
        'payment_details'       => '',
        'order_total'           => '',
        'start_date'            => '',
        'end_date'              => '',
        'start_time'            => '',
        'end_time'              => '',
        'calendar_event'        => '',
        'random_id'             => '',
        'seats'                 => '',
        'guests'                => '',
        'zoom'                  => '',
        'custom_form_data'      => '',
        'timezone'              => '',
        'recurrence'            => [],
        'parent'                => 0,
        'cancel_reason'         => '',
        'custom_location_url'   => '',
        'security_token'        => ''
    ];

    /**
     * Booking Constructor
     *
     * @return  void
     */
    public function __construct( $booking = 0 ) {
        if ( $booking instanceof self ) {
            $this->set_id( $booking->get_id() );
        } elseif ( ! empty( $booking->ID ) ) {
            $this->set_id( $booking->ID );
        } elseif ( is_numeric( $booking ) && $booking > 0 ) {
            $this->set_id( $booking );
        }
    }

    /**
     * Get id
     *
     * @return  integer
     */
    public function get_id() {
        return $this->id;
    }

    /**
     * Get customer id
     *
     * @return integer
     */
    public function get_customer_id() {
        return $this->get_prop( 'customer' );
    }

    /**
     * Get attendee id
     *
     * @return integer
     */
    public function get_attendees() {
        $attendees = $this->get_prop( 'attendees' );
        return $attendees ? $attendees : [];
    }

    /**
     * Get custom url
     *
     * @return string
     */
    public function get_custom_location_url() {
        $custom_url = $this->get_prop( 'custom_location_url' );
        return $custom_url ? $custom_url : '';
    }

    /**
     * Get staff for the current booking
     *
     * @return integer
     */
    public function get_staff_id() {
        return $this->get_prop( 'staff' );
    }

    /**
     * Get speaker id
     *
     * @return  integer
     */
    public function get_speaker_id() {
        return $this->get_prop( 'speaker' );
    }

    /**
     * Get booking type
     *
     * @return  string
     */
    public function get_type() {
        return $this->get_prop( 'type' );
    }

    /**
     * Get event id
     *
     * @return  integer
     */
    public function get_event_id() {
        return $this->get_prop( 'event' );
    }

    /**
     * Get customer
     *
     * @return  User Object
     */
    public function get_customer() {
        return get_userdata( $this->get_customer_id() );
    }

    /**
     * Get seats of a booking
     *
     * @return  array
     */
    public function get_seat() {
        return $this->get_prop( 'seats' );
    }

    /**
     * Get guests
     *
     * @return  array
     */
    public function get_guests() {
        return $this->get_prop( 'guests' );
    }

    /**
     * Get custom fields
     *
     * @return  array
     */
    public function get_custom_fields() {
        return $this->get_prop( 'custom_form_data' );
    }

    /**
     * Get billing first name
     *
     * @return  string
     */
    public function get_billing_first_name() {
        return $this->get_prop( 'first_name' );
    }

    /**
     * Get location
     *
     * @return string
     */
    public function get_location() {
        return $this->get_prop( 'location' );
    }

    /**
     * Get location type
     *
     * @return string
     */
    public function get_location_type() {
        return $this->get_prop( 'location_type' );
    }

    /**
     * Get booking time
     *
     * @return string
     */
    public function get_booking_time() {
        return $this->get_prop( 'booking_time' );
    }

    /**
     * Get last name
     *
     * @return  string
     */
    public function get_billing_last_name() {
        return $this->get_prop( 'last_name' );
    }

    /**
     * Get email
     *
     * @return  string
     */
    public function get_billing_email() {
        return $this->get_prop( 'email' );
    }

    /**
     * Get billing phone
     *
     * @return  string
     */
    public function get_billing_phone() {
        return $this->get_prop( 'phone' );
    }

    /**
     * Get timezone
     *
     * @return  string
     */
    public function get_timezone() {
        return $this->get_prop( 'timezone' );
    }

    /**
     * Get description
     *
     * @return string
     */
    public function get_description() {
        return $this->get_prop( 'description' );
    }

    /**
     * Get billing address
     *
     * @param   string  $adress_type  Booking billing address
     *
     * @return  string
     */
    public function get_billing_address( $adress_type = 'address_1' ) {
        return $this->get_prop( $adress_type );
    }

    /**
     * Get billing city
     *
     * @return string
     */
    public function get_billing_city() {
        return $this->get_prop( 'city' );
    }

    /**
     * Get billing state
     *
     * @return  string
     */
    public function get_billing_state() {
        return $this->get_prop( 'state' );
    }

    /**
     * Get billing post code
     *
     * @return  string
     */
    public function get_billing_post_code() {
        return $this->get_prop( 'post_code' );
    }

    /**
     * Get billing
     *
     * @return  string
     */
    public function get_billing_country() {
        return $this->get_prop( 'country' );
    }

    /**
     * Get payment method
     *
     * @return  string
     */
    public function get_payment_method() {
        return $this->get_prop( 'payment_method' );
    }

    /**
     * Get payment status
     *
     * @return  string
     */
    public function get_payment_status() {
        return $this->get_prop( 'payment_status' );
    }

    /**
     * Get appointment
     *
     * @return Appointment
     */
    public function get_appointment() {
        return $this->get_prop( 'appointment' );
    }

    /**
     * Get appointment name
     *
     * @return  string
     */
    public function get_appointment_name() {
        return $this->get_prop( 'appointment_name' );
    }

    /**
     * Get calendar event
     *
     * @return array
     */
    public function get_event() {
        return $this->get_prop( 'calendar_event' );
    }

    /**
     * Get post status
     *
     * @return  string
     */
    public function get_status() {
        return get_post( $this->id )->post_status;
    }

    /**
     * Get total
     *
     * @return  integer
     */
    public function get_total() {
        return $this->get_prop( 'order_total' );
    }

    /**
     * Get start date
     *
     * @return  string
     */
    public function get_start_date() {
        return $this->get_prop( 'start_date' );
    }
    /**
     * Get start date
     *
     * @return  string
     */
    public function get_date() {
        return $this->get_prop( 'date' );
    }

    /**
     * Get end date
     *
     * @return  string
     */
    public function get_end_date() {
        return $this->get_prop( 'end_date' );
    }

    /**
     * Get start time
     *
     * @return  string
     */
    public function get_start_time() {
        return $this->get_prop( 'start_time' );
    }

    /**
     * Get end time
     *
     * @return  string
     */
    public function get_end_time() {
        return $this->get_prop( 'end_time' );
    }

    /**
     * Get random id for the current booking
     *
     * @return  string
     */
    public function get_random_id() {
        return $this->get_prop( 'random_id' );
    }

    /**
     * Get payment details
     *
     * @return  array
     */
    public function get_payment_details() {
        return $this->get_prop( 'payment_details' );
    }

    /**
     * Get zoom meeting details
     *
     * @return  array
     */
    public function get_zoom() {
        return $this->get_prop( 'zoom' );
    }

    /**
     * Get recurrences
     *
     * @return  array
     */
    public function get_recurrence() {
        return $this->get_prop( 'recurrence' );
    }

    /**
     * Get parent
     *
     * @return  integer
     */
    public function get_parent() {
        return $this->get_prop( 'parent' );
    }

    /**
     * Get booking cancel reason
     *
     * @return  integer
     */
    public function get_cancel_reason() {
        return $this->get_prop( 'cancel_reason' );
    }

    /**
     * Get booking data
     *
     * @param   string  $prop
     *
     * @return  mixed
     */
    private function get_prop( $prop = '' ) {
        return $this->get_metadata( $prop );
    }

    /**
     * Get metadata
     *
     * @param   string  $prop
     *
     * @return  mixed
     */
    private function get_metadata( $prop = '' ) {
        $meta_key = $this->meta_prefix . $prop;

        return get_post_meta( $this->id, $meta_key, true );
    }

    /**
     * Generate random id
     *
     * @return  string
     */
    private function generate_randon_id() {
        return chr( wp_rand( ord( 'A' ), ord( 'Z' ) ) ) . wp_rand( 575639, 575639 + 400 );
    }

    // Setter.

    /**
     * Set booking id
     *
     * @param   integer  $id
     *
     * @return  void
     */
    public function set_id( $id ) {
        $this->id = $id;
    }

    /**
     * Set data
     *
     * @param   array  $args  Booking Args
     *
     * @return void
     */
    public function set_props( $args = [] ) {
        $this->data        = wp_parse_args( $args, $this->data );
        $this->appointment = new Appointment( $this->data['appointment'] );
        if ( isset( $this->data['type'] ) && 'timetics-event' ==  $this->data['type'] ) {
        $this->event       = new Event( $this->data['event'] );
        }

    }

    /**
     * Save metada for booking
     *
     * @return  void
     */
    public function save_metadata( $args = [] ) {
        foreach ( $args as $key => $value ) {

            // If a key not exists on data it will skip to save the data.
            if ( ! array_key_exists( $key, $this->data ) ) {
                continue;
            }

            // Prepare meta key.
            $meta_key = $this->meta_prefix . $key;

            if ( ! $value ) {
                $value = $this->get_prop( $key );
            }

            // Update appointment meta data.
            update_post_meta( $this->id, $meta_key, $value );
        }
    }

    /**
     * Save appointment
     *
     * @return void
     */
    public function save( $type = '' ) {
        $args = [
            'post_title'  => $this->appointment->get_name(),
            'post_type'   => $this->pos_type,
            'post_status' => $this->data['post_status'],
            'post_author' => get_current_user_id(),
        ];

        if ( 'timetics-event' == $type ) {
            $args = [
                'post_title'  => $this->event->get_name(),
                'post_type'   => $this->pos_type,
                'post_status' => $this->data['post_status'],
                'post_author' => get_current_user_id(),
            ];
        }

        $updated = false;

        if ( ! empty( $this->id ) ) {
            $this->send_notification( 'update' );
            $args['ID'] = $this->id;
            $updated    = true;
        }

        // Insert or Update appointment.
        $booking_id              = wp_insert_post( $args );
        $this->data['random_id'] = $this->generate_randon_id();
        $this->data = apply_filters( 'timetics/event/booking/all_data', $this->data);

        if ( ! is_wp_error( $booking_id ) ) {
            $this->set_id( $booking_id );
            $this->save_metadata( $this->data );
        }

        if ( ! $updated ) {
            $this->send_notification( 'create' );
        }
    }

    /**
     * Update booking
     *
     * @param   array  $args
     *
     * @return  bool
     */
    public function update( $args = [] ) {
        $post = get_post( $this->id )->to_array();
        $args = wp_parse_args( $args, $post );

        $updated = wp_update_post( $args );

        if ( ! is_wp_error( $updated ) ) {
            $this->save_metadata( $args );
        }

        return $updated;
    }

    /**
     * Find the Booking_Entry (shared per-slot schedule record) this booking occupies.
     *
     * @param  Appointment|null $meeting    Optional pre-built meeting for this booking.
     * @param  string           $start_date Slot date. Defaults to the booking's own.
     * @param  string           $start_time Slot start. Defaults to the booking's own.
     * @param  string           $timezone   Timezone of the two above. Defaults to the booking's own.
     * @return Booking_Entry|null           The first matching entry, or null if none.
     */
    private function find_slot_entry( $meeting = null, $start_date = '', $start_time = '', $timezone = '' ) {
        $meeting       = $meeting ?: new Appointment( $this->get_appointment() );
        $booking_entry = new Booking_Entry();

        $start_date = $start_date ?: $this->get_start_date();
        $start_time = $start_time ?: $this->get_start_time();
        $timezone   = $timezone ?: $this->get_timezone();

        $date_time = timetics_convert_timezone(
            $start_date . ' ' . $start_time,
            $timezone,
            $meeting->get_timezone()
        );

        $entries = $booking_entry->find(
            [
                'staff_id'   => $this->get_staff_id(),
                'meeting_id' => $this->get_appointment(),
                'date'       => $date_time->format( 'Y-m-d' ),
                'start'      => $date_time->format( 'h:i a' ),
            ]
        );

        return $entries ? $booking_entry->first() : null;
    }

    /**
     * Release the slot held by this booking.
     *
     * @return void
     */
    public function release_slot() {
        if ( ! $this->is_booking() ) {
            return;
        }

        // Idempotency guard: never release the same booking's slot twice.
        if ( $this->get_prop( 'slot_released' ) ) {
            return;
        }

        $this->release_slot_at( $this->get_start_date(), $this->get_start_time() );

        update_post_meta( $this->id, $this->meta_prefix . 'slot_released', 1 );
    }

    /**
     * Take back the slot this booking released.
     *
     * A declined payment marks the booking failed and releases its slot, so an
     * attempt the customer walks away from does not block the time forever. When
     * the customer retries on that same booking the slot has to be taken back
     * before any money moves, otherwise the booking finalizes while the slot still
     * reads as free and the next customer can book straight over it.
     *
     * @return  bool  True when the slot is held, false when it is no longer free.
     */
    public function reserve_slot() {
        if ( ! $this->is_booking() ) {
            return false;
        }

        // Nothing was released, so the slot is still held from booking creation.
        if ( ! $this->get_prop( 'slot_released' ) ) {
            return true;
        }

        $meeting = new Appointment( $this->get_appointment() );
        $entry   = $this->find_slot_entry( $meeting );
        $seats   = ! empty( $this->get_seat() ) ? (array) $this->get_seat() : [];
        $taking  = max( 1, count( $seats ) );

        if ( $entry ) {
            $booked = intval( $entry->get_booked() ) + $taking;

            // Someone else took the time while this booking was in the failed state.
            if ( $booked > $meeting->get_effective_capacity() ) {
                return false;
            }

            $existing = ! empty( $entry->get_seats() ) ? (array) $entry->get_seats() : [];

            $entry->update(
                [
                    'booked' => $booked,
                    'seats'  => array_values( array_unique( array_merge( $existing, $seats ) ) ),
                ]
            );
        } else {
            // A one-to-one release deletes the entry outright, so recreate it. Entries
            // are stored in the meeting's timezone, the booking's in the customer's.
            $start = timetics_convert_timezone( $this->get_start_date() . ' ' . $this->get_start_time(), $this->get_timezone(), $meeting->get_timezone() );
            $end   = timetics_convert_timezone( $this->get_start_date() . ' ' . $this->get_end_time(), $this->get_timezone(), $meeting->get_timezone() );

            $booking_entry = new Booking_Entry();

            $booking_entry->create(
                [
                    'meeting_id'  => $meeting->get_id(),
                    'staff_id'    => $this->get_staff_id(),
                    'customer_id' => $this->get_customer(),
                    'booking_id'  => $this->id,
                    'booked'      => $taking,
                    'date'        => $start->format( 'Y-m-d' ),
                    'start'       => $start->format( 'h:i a' ),
                    'end'         => $end->format( 'h:i a' ),
                    'seats'       => $seats,
                ]
            );
        }

        delete_post_meta( $this->id, $this->meta_prefix . 'slot_released' );

        return true;
    }

    /**
     * Release the entry for one named slot of this booking.
     *
     * Rescheduling saves the new time first, so the old slot has to be named
     * rather than read off the booking. No idempotency guard: the booking lives
     * on and may release more slots as it moves.
     *
     * @param   string  $start_date  Slot date, Y-m-d.
     * @param   string  $start_time  Slot start.
     * @param   string  $timezone    Timezone of the two above. Defaults to the booking's own.
     *
     * @return  void
     */
    public function release_slot_at( $start_date, $start_time, $timezone = '' ) {
        if ( ! $this->is_booking() ) {
            return;
        }

        $meeting = new Appointment( $this->get_appointment() );
        $entry   = $this->find_slot_entry( $meeting, $start_date, $start_time, $timezone );

        if ( ! $entry ) {
            if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug-only diagnostics for a missing booking entry.
                error_log(
                    sprintf(
                        'Timetics release_slot: no Booking_Entry found for booking #%d ( staff %s, meeting %s, slot %s %s ).',
                        $this->id,
                        $this->get_staff_id(),
                        $this->get_appointment(),
                        $start_date,
                        $start_time
                    )
                );
            }

            return;
        }

        if ( 'one-to-one' === strtolower( $meeting->get_type() ) ) {
            $entry->delete();

            return;
        }

        $booked        = max( 0, intval( $entry->get_booked() ) - 1 );
        $booked_seat   = ! empty( $this->get_seat() ) ? $this->get_seat() : [];
        $existing_seat = ! empty( $entry->get_seats() ) ? $entry->get_seats() : [];

        $entry->update(
            [
                'booked' => $booked,
                'seats'  => array_values( array_diff( $existing_seat, $booked_seat ) ),
            ]
        );
    }

    /**
     * Delete booking
     *
     * @return bool | WP_Error
     */
    public function delete() {
        // Set action for sending notification.
        $this->send_notification( 'delete' );

        return wp_delete_post( $this->id, true );
    }

    /**
     * Check is a booking or not
     *
     * @return  bool
     */
    public function is_booking() {
        $post = get_post( $this->id );

        if ( $post && $this->pos_type === $post->post_type ) {
            return true;
        }

        return false;
    }

    /**
     * Get all bookings
     *
     * @param   array  $args  Bookings args
     *
     * @return  array
     */
    public static function all( $args = [] ) {

        $status = [
            'approved',
            'pending',
            'cancel',
            'completed',
        ];
        $defaults = [
            'post_type'      => 'timetics-booking',
            'posts_per_page' => 20,
            'post_status'    => 'any',
            'paged'          => 1,
        ];

        $args = wp_parse_args( $args, $defaults );

        $args = apply_filters( 'timetics/admin/bookings/all', $args, $defaults );

        $meta_query = ! empty( $args['meta_query'] ) && is_array( $args['meta_query'] ) ? $args['meta_query'] : [];

        if ( ! empty( $args['start_date'] ) ) {
            $meta_query[] = [
                'key'     => '_tt_booking_start_date',
                'value'   => $args['start_date'],
                'compare' => '>=',
                'type'    => 'DATE',
            ];
        }

        if ( ! empty( $args['meeting'] ) ) {
            $meta_query[] = [
                'key'     => '_tt_booking_appointment',
                'value'   => $args['meeting'],
                'compare' => '=',
            ];
        }

        if ( ! empty( $args['staff'] ) ) {
            $meta_query[] = [
                'key'     => '_tt_booking_staff',
                'value'   => $args['staff'],
                'compare' => '=',
            ];
        }

        if ( ! empty( $meta_query ) ) {
            if ( count( $meta_query ) > 1 && empty( $meta_query['relation'] ) ) {
                $meta_query['relation'] = 'AND';
            }
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering bookings
            $args['meta_query'] = $meta_query;
        }

        $post = new WP_Query( $args );

        return [
            'total' => $post->found_posts,
            'items' => $post->posts,
        ];
    }

    /**
     * Create event after creating a booking
     *
     * @return bool
     */
    public function create_event() {

        if ( ! timetics_google_setup() ) {
            return;
        }

        if ('timetics-event' == $this->get_type()) {
            Event_Booking::instance()->create_online_event($this->get_id());
        }else {
             $this->create_appointment_event();
        }

    }

    public function create_appointment_event() {
        // No summary/description passed, so prepare_event() falls back to the
        // meeting's own name and description. The booking-created email subject
        // and body used to be reused here, which put the notification copy
        // ("New meeting scheduled!", greeting, date and time lines) into the
        // calendar entry instead of the meeting's details.
        $calendar   = new Calendar();
        $event_data = $this->prepare_event();

        if ( ! $event_data ) {
            return;
        }

        $entry = $this->find_slot_entry();

        $event = false;

        if ( $entry ) {
            $event = $entry->get_google_event();

            if ( ! $event ) {
                $event = $calendar->create_event( $event_data );
                $entry->update( [
                    'google_event' => $event,
                ] );
            }
        }

        if ( $event ) {
            update_post_meta( $this->id, $this->meta_prefix . 'calendar_event', $event );

            // Also record the bare event id. Google_Calendar_Sync uses this meta
            // to recognise events Timetics itself created, so that they are not
            // pulled back in as external events and used to block their own slot.
            if ( ! empty( $event['id'] ) ) {
                $this->set_google_event_id( $event['id'] );
            }
        }
    }

    /**
     * Update calendar event
     *
     * @return bool
     */
    public function update_event() {
        if ( ! timetics_google_setup() ) {
            return;
        }

        // Same as create_appointment_event(): the meeting's name and description
        // belong in the calendar entry, not the reschedule email copy. Keeping
        // them consistent also stops a reschedule from rewriting the title.
        $calendar       = new Calendar();
        $event_data     = $this->prepare_event();
        $calendar_event = $this->get_event();

        if ( ! is_array( $calendar_event ) ) {
            return;
        }

        if ( ! empty( $calendar_event['id'] ) ) {
            // Update calendar event.
            $event = $calendar->update_event( $calendar_event['id'], $event_data );

            // update calendar event data.
            update_post_meta( $this->id, $this->meta_prefix . 'calendar_event', $event );
        }
    }

    /**
     * Delete calendar event
     *
     * @return
     */
    public function delete_event() {
        if ( ! timetics_google_setup() ) {
            return;
        }

        $calendar       = new Calendar();
        $calendar_event = $this->get_event();
        $access_token   = timetics_get_google_access_token( $this->get_staff_id() );

        if ( ! is_array( $calendar_event ) ) {
            return;
        }

        if ( ! $access_token ) {
            return;
        }

        if ( ! empty( $calendar_event['id'] ) ) {
            // Update calendar event.
            $event = $calendar->delete_event( $calendar_event['id'], $access_token );

            // update calendar event data.
            update_post_meta( $this->id, $this->meta_prefix . 'calendar_event', $event );

            // The event no longer exists in Google, so drop the id used by
            // Google_Calendar_Sync to skip Timetics-created events.
            $this->set_google_event_id( '' );
        }
    }

    /**
     * Prepare event data
     *
     * @return  array
     */
    private function prepare_event( $data = [] ) {

        $meeting_id    = $this->get_appointment();
        $staff_id      = $this->get_staff_id();
        $customer_id   = $this->get_customer_id();
        $location_type = $this->get_location_type();
        $access_token  = timetics_get_google_access_token( $staff_id );

        if ( ! $access_token ) {
            return false;
        }

        $meeting  = new Appointment( $meeting_id );
        $staff    = new Staff( $staff_id );
        $customer = new Customer( $customer_id );

        $time     = gmdate( 'h:i a', strtotime( $this->get_start_time() ) );
        $date     = gmdate( 'd F Y', strtotime( $this->get_start_date() ) );
        $location = $this->get_location();

        $placeholders = [
            '{%meeting_title%}'    => $meeting->get_name(),
            '{%meeting_date%}'     => $date,
            '{%meeting_time%}'     => $time,
            '{%meeting_location%}' => $location,
            '{%meeting_duration%}' => $meeting->get_duration(),
            '{%host_name%}'        => $staff->get_display_name(),
            '{%host_email%}'       => $staff->get_email(),
            '{%customer_name%}'    => $customer->get_display_name(),
            '{%customer_email%}'   => $customer->get_email(),
        ];

        $summary     = ! empty( $data['summary'] ) ? timetics_replace_placeholder( $data['summary'], $placeholders ) : $meeting->get_name();
        $description = ! empty( $data['description'] ) ? timetics_replace_placeholder( $data['description'], $placeholders ) : $meeting->get_description();

        $description = apply_filters( 'timetics_booking_event_data', $description, $summary, $this );

        $event = [
            'summary'      => $summary,
            'description'  => $description,
            'start'        => [
                'date' => $this->get_start_date(),
                'time' => $this->get_start_time(),
            ],
            'end'          => [
                'date' => $this->get_end_date(),
                'time' => $this->get_end_time(),
            ],
            'attendees'    => [
                ['email' => $staff->get_email()],
                ['email' => $customer->get_email()],
            ],
            'timezone'     => $this->get_timezone(),
            'google_meet'  => 'google-meet' === $location_type,
            'access_token' => $access_token,
        ];

        return $event;
    }

    /**
     * Get all data of an object
     *
     * @return  array
     */
    public function to_array() {
        $data = [];

        foreach ( $this->data as $key => $value ) {

            $data[$key] = $this->get_prop( $key );
        }

        return $data;
    }

    /**
     * Send notification after booking
     *
     * @return  void
     */
    private function send_notification( $action ) {

        do_action( 'timetics_booking_notification', $this, $action );
    }

    /**
     * Get booking ids by appointment id
     *
     * @param   array  Collection of appointment ids
     *
     * @return  array Collection of booking ids
     */
    public static function get_booking_ids_by_appointment( $appointment_ids, $per_page = -1 ) {
        if ( empty( $appointment_ids ) ) {
            return [];
        }

        $query = new WP_Query([
            'post_type'      => 'timetics-booking',
            'post_status'    => 'any',
            'posts_per_page' => $per_page,
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering bookings by appointment
            'meta_query'     => [
                [
                    'key'     => '_tt_booking_appointment',
                    'value'   => $appointment_ids,
                    'compare' => 'IN',
                ],
            ],
        ]);

        return [
            'total' => $query->found_posts,
            'items' => $query->posts,
        ];
    }

    /**
     * Get booking ids visible to a given user.
     *
     * Union of:
     *  - bookings where the user is the assigned staff (`_tt_booking_staff`)
     *  - bookings whose appointment is authored by the user
     *
     * @param   integer  $user_id  WP user id.
     *
     * @return  int[]    Deduped list of booking post ids.
     */
    public static function get_visible_ids_for_user( $user_id ) {
        $user_id = (int) $user_id;

        if ( ! $user_id ) {
            return [];
        }

        $staff_ids = get_posts( [
            'post_type'      => 'timetics-booking',
            'post_status'    => 'any',
            'posts_per_page' => -1,
            'fields'         => 'ids',
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- needed to scope bookings to current staff user
            'meta_query'     => [
                [
                    'key'   => '_tt_booking_staff',
                    'value' => $user_id,
                ],
            ],
        ] );

        $appointment_ids = Appointment::get_appointment_ids_by_author( $user_id );

        $appointment_booking_ids = [];
        if ( ! empty( $appointment_ids ) ) {
            $appointment_booking_ids = get_posts( [
                'post_type'      => 'timetics-booking',
                'post_status'    => 'any',
                'posts_per_page' => -1,
                'fields'         => 'ids',
                // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- needed to scope bookings to appointments owned by current user
                'meta_query'     => [
                    [
                        'key'     => '_tt_booking_appointment',
                        'value'   => $appointment_ids,
                        'compare' => 'IN',
                    ],
                ],
            ] );
        }

        return array_values( array_unique( array_map( 'intval', array_merge( $staff_ids, $appointment_booking_ids ) ) ) );
    }

    /**
     * Get Google Calendar Event ID for this booking
     *
     * @return string
     */
    public function get_google_event_id() {
        return $this->get_metadata( 'google_event_id' );
    }

    /**
     * Set Google Calendar Event ID for this booking
     *
     * @param string $event_id
     * @return bool
     */
    public function set_google_event_id( $event_id ) {
        // Written directly rather than through save_metadata(), which takes an
        // array and only accepts keys declared in $this->data — neither is true
        // here, so it silently discarded every write.
        $meta_key = $this->meta_prefix . 'google_event_id';

        if ( ! $event_id ) {
            return delete_post_meta( $this->id, $meta_key );
        }

        return update_post_meta( $this->id, $meta_key, $event_id );
    }

    public function set_sync_status( $status ) {
        $meta_key = $this->meta_prefix . 'google_calendar_sync_status';

        if ( ! $status ) {
            return delete_post_meta( $this->id, $meta_key );
        }

        return update_post_meta( $this->id, $meta_key, $status );
    }

    public function get_sync_status() {
        return $this->get_metadata( 'google_calendar_sync_status' );
    }
    /**
     * Get all Google Event IDs for all bookings
     *
     * @return array
     */
    public function get_all_google_event_ids() {
        $meta_key = $this->meta_prefix . 'google_event_id';

        $posts = get_posts(
            array(
                'post_type'      => 'any',
                'posts_per_page' => -1,
                // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering posts by meta key
                'meta_query'     => array(
                    array(
                        'key'     => $meta_key,
                        'value'   => '',
                        'compare' => '!=',
                    ),
                ),
                'fields'         => 'ids',
            )
        );

        if ( empty( $posts ) ) {
            return array();
        }

        $event_ids = array();
        foreach ( $posts as $post_id ) {
            $event_id = get_post_meta( $post_id, $meta_key, true );
            if ( ! empty( $event_id ) ) {
                $event_ids[] = $event_id;
            }
        }

        return $event_ids;
    }

    /**
     * Get name.
     *
     * @since 1.0.0
     *
     * @return string
     */
    public function get_security_token() {
        return $this->get_prop( 'security_token' );
    }

    /**
     * Generate and store a secure reschedule token for a booking
     *
     * @param int $booking_id
     * @return string The generated token
     */
    public function generate_security_token() {
        $token = bin2hex(random_bytes(4)); // 8 hex chars
        return $token;
    }

    /**
     * Rotate the booking's security token so the previously-issued one cannot
     * be reused (e.g. after a payment is approved).
     *
     * @return void
     */
    public function rotate_security_token() {
        $meta_key  = $this->meta_prefix . 'security_token';
        $new_token = $this->generate_security_token();
        update_post_meta( $this->id, $meta_key, $new_token );
    }

    /**
     * Get the Stripe PaymentIntent id bound to this booking, if any.
     *
     * @return string
     */
    public function get_stripe_payment_intent_id() {
        return (string) get_post_meta( $this->id, '_tt_stripe_payment_intent_id', true );
    }

    /**
     * Bind a Stripe PaymentIntent id to this booking. Used for replay
     * protection: a second make_payment call with a different intent will be
     * rejected.
     *
     * @param string $intent_id
     * @return void
     */
    public function set_stripe_payment_intent_id( $intent_id ) {
        update_post_meta( $this->id, '_tt_stripe_payment_intent_id', sanitize_text_field( (string) $intent_id ) );
    }
}

```
