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 ); } }