# booktics/1.0.27/core/appointment/services/appointment-validator.php

Booktics – Appointment Booking Calendar for Service Businesses, version 1.0.27. 327 lines.

- Page: https://pluginprobe.com/plugins/booktics/1.0.27/code/core/appointment/services/appointment-validator.php
- Raw: https://pluginprobe.com/plugins/booktics/1.0.27/raw/core/appointment/services/appointment-validator.php
- Modified: 2026-09-28T15:21:16+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/booktics/1.0.27/code/core/appointment/services/appointment-validator.php#L10-L20`.

```php
<?php
namespace Booktics\Appointment\Services;

use Booktics\Models\Appointment_Model;
use Booktics\Models\Service_Model;
use Booktics\Utils\Singleton;
use WP_Error;

class Appointment_Validator {

    use Singleton;

    /**
     * Validate data for creating an appointment.
     * @param array $data
     * @return WP_Error|null
     */
    public static function validate_create( array $data ) {
        if ( empty( $data['start_time'] ) ) {
            return new WP_Error( 'missing_start_time', __( 'Start time is required', 'booktics' ) );
        }
        if ( empty( $data['end_time'] ) ) {
            return new WP_Error( 'missing_end_time', __( 'End time is required', 'booktics' ) );
        }
        if ( empty( $data['date'] ) ) {
            return new WP_Error( 'missing_date', __( 'Date is required', 'booktics' ) );
        }
        if ( empty( $data['service_id'] ) ) {
            return new WP_Error( 'missing_service_id', __( 'Service ID is required', 'booktics' ) );
        }
        if ( empty( $data['team_member_id'] ) ) {
            return new WP_Error( 'missing_team_member_id', __( 'Team member ID is required', 'booktics' ) );
        }
        // Add more validation as needed
        return null;
    }

    /**
     * Validate data for updating an appointment.
     * @param array $data
     * @return WP_Error|null
     */
    public static function validate_update( array $data ): ?WP_Error {
        if ( empty( $data ) ) {
            return new WP_Error( 'no_data', __( 'No data provided for update', 'booktics' ) );
        }
        // Optionally, check for required fields on update
        return null;
    }

    /**
     * Validate data for rescheduling an appointment.
     * @param array $data
     * @return WP_Error|null
     */
    public static function validate_reschedule( array $data, $id ) {
        if ( empty( $data['start_time'] ) ) {
            return new WP_Error( 'missing_start_time', __( 'Start time is required for reschedule', 'booktics' ) );
        }
        if ( empty( $data['end_time'] ) ) {
            return new WP_Error( 'missing_end_time', __( 'End time is required for reschedule', 'booktics' ) );
        }
        if ( empty( $data['date'] ) ) {
            return new WP_Error( 'missing_date', __( 'Date is required for reschedule', 'booktics' ) );
        }
        if ( ! booktics_get_option( 'allow_customer_rescheduling', false ) ) {
            return new WP_Error( 'reschedule_setting', __( 'Customer rescheduling is not allowed', 'booktics' ) );
        }

        if ( empty( $data['security_token'] ) ) {
            return new WP_Error( 'missing_appointment_token', __( 'Appointment token is required for reschedule', 'booktics' ) );
        }

        $appointment = Appointment_Model::find( $id );

        if ( ! $appointment ) {
            return new WP_Error( 'appointment_not_found', __( 'Appointment not found', 'booktics' ) );
        }

        if ( 'canceled' === $appointment->status ) {
            return new WP_Error( 'appointment_cancelled', __( 'This Appointment has already been cancelled', 'booktics' ) );
        }

        $token = isset( $data['security_token'] ) ? (string) $data['security_token'] : '';
        if ( ! hash_equals( (string) $appointment->security_token, $token ) && ! current_user_can( 'manage_options' ) ) {
            return new WP_Error( 'invalid_security_token', __( 'You dont have permission for rescheduling this appointment', 'booktics' ) );
        }

        $reschedule_restrictions = booktics_get_option( 'reschedule_restriction', array( 'enable' => false ) );
        if ( isset( $reschedule_restrictions['enable'] ) && $reschedule_restrictions['enable'] ) {
            $restricted_time = $reschedule_restrictions['restricted_time'] ?? 0;
            $restricted_unit = $reschedule_restrictions['restricted_unit'] ?? 'hr';
            $restricted_min  = ('min' === $restricted_unit) ? $restricted_time : $restricted_time * 60;

            $current_time = (int) wp_date( 'U' );
            $appointment_time = strtotime( $appointment->date . ' ' . $appointment->start_time );
            if ( $appointment_time < $current_time + $restricted_min ) {
                return new WP_Error(
                    'reschedule_restricted', sprintf(
                        /* translators: %1$d: restricted time value, %2$s: unit (e.g., 'min' or 'hr') */
                        __( 'You can only reschedule appointments at least %1$d %2$s before the appointment time.', 'booktics' ),
                        $restricted_time,
                        $restricted_unit
                    )
                );
            }
        }

        return null;
    }

    /**
     * Validate data for canceling an appointment.
     * @param int $id
     * @return WP_Error|null
     */
    public function validate_cancel( $id ) {
        if ( ! booktics_get_option( 'allow_customer_cancellation', false ) ) {
            return new WP_Error( 'cancellation_setting', __( 'Customer cancellation is not allowed', 'booktics' ) );
        }

        $cancellation_restrictions = booktics_get_option( 'cancellation_restriction', array( 'enable' => false ) );
        if ( isset( $cancellation_restrictions['enable'] ) && $cancellation_restrictions['enable'] ) {
            $restricted_time = $cancellation_restrictions['restricted_time'] ?? 0;
            $restricted_unit = $cancellation_restrictions['restricted_unit'] ?? 'hr';
            $restricted_min  = $restricted_unit === 'min' ? $restricted_time : $restricted_time * 60;
            $appointment = Appointment_Model::find( $id );

            if ( ! $appointment ) {
                return new WP_Error( 'appointment_not_found', __( 'Appointment not found', 'booktics' ) );
            }

            $current_time = (int) wp_date( 'U' );
            $appointment_time = strtotime( $appointment->date . ' ' . $appointment->start_time );
            if ( $appointment_time < $current_time + $restricted_min ) {
                return new WP_Error(
                    'cancellation_restricted', sprintf(
                         /* translators: %1$d: number of units, %2$s: unit (e.g., 'hr' or 'min') */
                        __( 'You can only cancel appointments at least %1$d %2$s before the appointment time.', 'booktics' ),
                        $restricted_time,
                        $restricted_unit
                    )
                );
            }
        }

        return null;
    }

    /**
     * Check that the requested slot is still free for the requested team member.
     *
     * Guards the window between a customer seeing a slot and submitting the
     * booking. Overlap is checked against the same date and team member, so an
     * appointment that starts before the request ends and ends after it starts
     * is a conflict.
     *
     * @param array $data Appointment data. Requires date, start_time, team_member_id.
     *
     * @return \WP_Error|null Null when the slot is free.
     */
    public static function validate_slot_available( array $data ) {
        $team_member_id = absint( $data['team_member_id'] ?? 0 );
        $date           = $data['date'] ?? '';
        $start          = $data['start_time'] ?? '';

        if ( ! $team_member_id || ! $date || ! $start ) {
            return null;
        }

        $request_start = strtotime( $start );

        // strtotime() answers false on anything it cannot read, and false + 1800
        // is 1800, which is a window near the epoch that matches nothing. That
        // would pass every booking through, so refuse instead.
        if ( false === $request_start ) {
            return new WP_Error(
                'invalid_start_time',
                __( 'That start time could not be read. Please choose another slot.', 'booktics' )
            );
        }

        $duration = self::duration_in_minutes( $data, $request_start );

        if ( $duration < 1 ) {
            return new WP_Error(
                'invalid_duration',
                __( 'That appointment length could not be worked out. Please choose another slot.', 'booktics' )
            );
        }

        $request_end = $request_start + ( $duration * 60 );
        $requested   = max( 1, absint( $data['total_attendees'] ?? 1 ) );
        $capacity    = self::slot_capacity( absint( $data['service_id'] ?? 0 ) );

        $existing = Appointment_Model::get_appointment_ids_for_slot( $date, $team_member_id, self::blocking_statuses() );

        $booked = 0;

        foreach ( $existing as $appointment_id ) {
            if ( ! empty( $data['id'] ) && (int) $data['id'] === (int) $appointment_id ) {
                continue;
            }

            $existing_start = strtotime( (string) get_post_meta( $appointment_id, 'start_time', true ) );

            // An unreadable stored time cannot be compared. Treat it as
            // blocking rather than silently letting a second booking in.
            if ( false === $existing_start ) {
                $booked += 1;
                continue;
            }

            $existing_duration = absint( get_post_meta( $appointment_id, 'duration', true ) );
            if ( $existing_duration < 1 ) {
                $existing_duration = $duration;
            }

            $existing_end = $existing_start + ( $existing_duration * 60 );

            if ( $request_start < $existing_end && $request_end > $existing_start ) {
                $booked += max( 1, absint( get_post_meta( $appointment_id, 'total_attendees', true ) ) );
            }
        }

        if ( $booked + $requested > $capacity ) {
            return new WP_Error(
                'slot_no_longer_available',
                __( 'That time was just taken. Please choose another slot.', 'booktics' )
            );
        }

        return null;
    }

    /**
     * How long the appointment being checked runs for, in minutes.
     *
     * Prefers an explicit duration, then the gap between the submitted start
     * and end times, then the service's own length. A bare literal is never
     * used: guessing 30 on a 60 minute service would miss every conflict in
     * the second half hour.
     *
     * @param array $data          Appointment data.
     * @param int   $request_start Parsed start timestamp.
     *
     * @return int Minutes, or 0 when it cannot be worked out.
     */
    private static function duration_in_minutes( array $data, $request_start ) {
        $duration = absint( $data['duration'] ?? 0 );

        if ( $duration > 0 ) {
            return $duration;
        }

        $end = strtotime( (string) ( $data['end_time'] ?? '' ) );

        if ( false !== $end && $end > $request_start ) {
            return (int) round( ( $end - $request_start ) / 60 );
        }

        $service = absint( $data['service_id'] ?? 0 );

        if ( $service > 0 ) {
            $model = Service_Model::find( $service );

            // Post_Model has __get() but no __isset(), so read the value and
            // then test it rather than using empty() on the property.
            $durations = $model ? $model->additional_durations : array();
            $first     = is_array( $durations ) && isset( $durations[0] ) ? $durations[0] : array();

            if ( ! empty( $first['duration_time'] ) ) {
                $length = absint( $first['duration_time'] );

                return ( isset( $first['duration_unit'] ) && 'hour' === $first['duration_unit'] ) ? $length * 60 : $length;
            }
        }

        return 0;
    }

    /**
     * Statuses that make an existing appointment block a slot.
     *
     * The site setting decides, the same as it does for slot generation, but a
     * cancelled appointment never blocks. Both spellings are dropped because
     * both are accepted statuses and older rows use the British one.
     *
     * @return array
     */
    private static function blocking_statuses() {
        $statuses = (array) booktics_get_option( 'appointment_statuses_for_blocked_slot', array( 'publish', 'active', 'pending' ) );
        $statuses = array_diff( $statuses, array( 'canceled', 'cancelled' ) );

        return $statuses ? array_values( $statuses ) : array( 'publish', 'active', 'pending' );
    }

    /**
     * How many attendees one slot of this service can hold.
     *
     * Core holds one. Booktics Pro raises it to the group booking maximum
     * through the filter, so a second attendee joining an existing booking is
     * not treated as a conflict.
     *
     * @param int $service_id Service post ID.
     *
     * @return int Capacity, at least 1.
     */
    private static function slot_capacity( $service_id ) {
        if ( $service_id < 1 ) {
            return 1;
        }

        /**
         * Filters how many attendees one slot of a service can hold.
         *
         * @since 1.0.26
         *
         * @param int $capacity   Capacity, at least 1.
         * @param int $service_id Service post ID.
         */
        $capacity = apply_filters( 'booktics_slot_capacity', 1, $service_id );

        return max( 1, (int) $capacity );
    }
}

```
