'text', '_wpnonce' => 'text', 'nonce' => 'text', 'booking_id' => 'text', 'booking_kind' => 'text', 'service_id' => 'text', 'staff_id' => 'text', 'resource_staff_id' => 'text', 'location_id' => 'text', 'date' => 'text', 'start_local' => 'text', 'status_id' => 'text', 'payment_status' => 'text', 'price' => 'text', 'currency' => 'text', 'first_name' => 'text', 'last_name' => 'text', 'email' => 'email', 'phone' => 'text', 'phone_country' => 'text', 'selected_customer_id' => 'text', 'customer_note' => 'textarea', 'internal_note' => 'textarea', 'resource_category' => 'text', 'resource_id' => 'text', 'resource_choice_id' => 'text', 'resource_board_start' => 'text', 'resource_quote_currency' => 'text', 'resource_quote_cents' => 'text', 'resource_quote_minutes' => 'text', 'party_size' => 'text', 'abientot_recurrence_enabled' => 'text', 'abientot_recurrence_first' => 'text', 'abientot_recurrence_uuid' => 'text', 'abientot_recurrence_interval' => 'text', 'abientot_recurrence_count' => 'text', 'abientot_recurrence_overrides' => 'textarea', 'variant_id' => 'text', 'extras' => 'text', 'answers' => 'textarea', 'selection_config_digest' => 'text', 'selection_definition_version' => 'text', 'credit_token' => 'text', 'credit_grant_id' => 'text', 'credit_use_package' => 'text', 'credit_submission_uuid' => 'text', ); private AppointmentsRepository $appointments; private CustomersRepository $customers; private ServicesRepository $services; private StaffRepository $staff; private LocationsRepository $locations; private StatusesRepository $statuses; private PaymentRecordsRepository $payments; private ActivityLogRepository $activity; private AvailabilityService $availability; private EmailService $emails; /** @var array> Notifications deferred until the booking transaction commits. */ private array $pending_notifications = array(); private bool $owns_booking_lock = false; private bool $booking_transaction_active = false; /** Fields of the booking request, set by handlers after verifying the nonce. */ private ?Input $input = null; /** * Extension-supplied values that replace request fields while one booking is persisted. * * @var array */ private array $overrides = array(); public function __construct() { $this->appointments = new AppointmentsRepository(); $this->customers = new CustomersRepository(); $this->services = new ServicesRepository(); $this->staff = new StaffRepository(); $this->locations = new LocationsRepository(); $this->statuses = new StatusesRepository(); $this->payments = new PaymentRecordsRepository(); $this->activity = new ActivityLogRepository(); $this->availability = new AvailabilityService(); $this->emails = new EmailService(); } public function register(): void { add_action( 'admin_post_abientot_save_booking', array( $this, 'save_booking' ) ); add_action( 'admin_post_abientot_booking_status', array( $this, 'change_status' ) ); add_action( 'admin_post_abientot_mark_booking_paid', array( $this, 'mark_paid' ) ); add_action( 'admin_post_abientot_update_booking_payment', array( $this, 'update_payment_status' ) ); add_action( 'admin_post_abientot_export_bookings', array( $this, 'export_csv' ) ); add_action( 'current_screen', array( $this, 'render_panel_request' ) ); add_action( 'wp_ajax_abientot_admin_booking_slots', array( $this, 'ajax_slots' ) ); add_action( 'wp_ajax_abientot_ajax_save_booking', array( $this, 'ajax_save_booking' ) ); add_action( 'wp_ajax_abientot_quick_update_booking', array( $this, 'ajax_quick_update' ) ); add_action( 'wp_ajax_abientot_booking_change_preview', array( $this, 'ajax_booking_change_preview' ) ); add_action( 'wp_ajax_abientot_reschedule_booking', array( $this, 'ajax_reschedule_booking' ) ); add_action( 'wp_ajax_abientot_customer_autocomplete', array( $this, 'ajax_customer_autocomplete' ) ); } public function ajax_customer_autocomplete(): void { check_ajax_referer( 'abientot_customer_autocomplete', 'nonce' ); if ( ! current_user_can( 'abientot_manage_bookings' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to manage bookings.', 'abientot' ) ), 403 ); } $query = isset( $_GET['query'] ) ? sanitize_text_field( wp_unslash( $_GET['query'] ) ) : ''; $field = isset( $_GET['field'] ) ? sanitize_key( wp_unslash( $_GET['field'] ) ) : ''; $rows = $this->customers->autocomplete( $query, $field, 8 ); wp_send_json_success( array_map( static fn ( array $row ): array => array( 'id' => (int) $row['id'], 'firstName' => (string) $row['first_name'], 'lastName' => (string) $row['last_name'], 'name' => (string) $row['display_name'], 'email' => (string) $row['email'], 'phone' => (string) $row['phone'], ), $rows ) ); } public function render_bookings(): void { if ( ! current_user_can( 'abientot_manage_bookings' ) ) { wp_die( esc_html__( 'You do not have permission to manage bookings.', 'abientot' ) ); } $panel_only = $this->is_panel_request(); $filters = $this->filters_from_request(); $default = ! array_filter( array( 's', 'status_id', 'service_id', 'staff_id', 'payment_status', 'date_from', 'date_to' ), static fn( string $key ): bool => null !== Request::view( $key ) ); if ( $default ) { $timezone = DateTimeHelper::business_timezone(); $today = wp_date( 'Y-m-d', null, $timezone ); $filters = array_merge( $filters, array( 'date_from' => $today, 'date_to' => $today, 'date_from_utc' => DateTimeHelper::local_to_utc_sql( $today . ' 00:00:00', $timezone ), 'date_to_utc' => DateTimeHelper::local_to_utc_sql( $today . ' 23:59:59', $timezone ), 'order' => 'ASC', ) ); } $page_number = max( 1, (int) ( Request::view( 'paged', 'int' ) ?? 1 ) ); $limit = 25; $offset = ( $page_number - 1 ) * $limit; $bookings = $panel_only ? array() : $this->appointments->list_for_admin( $filters, $limit, $offset ); $total = $panel_only ? 0 : $this->appointments->count_for_admin( $filters ); $edit_id = (int) Request::view( 'booking_id', 'int' ); $booking = $edit_id > 0 ? $this->appointments->find_for_admin( $edit_id ) : null; $services = $this->services->list_for_admin(); $statuses = $this->statuses->ordered(); $staff = \Abientot\Support\BookingStaff::resolve( 'admin', $booking ); $location = \Abientot\Support\BookingLocation::resolve( 'admin', $booking ); if ( ! $booking ) { try { // List filters share query names with Discovery's creation context. // Staff/location choices require an explicit creation prefill. $creation_prefill = ! empty( Request::view( 'start_local' ) ) || ! empty( Request::view( 'customer_id' ) ); $choice = ! apply_filters( 'abientot_admin_booking_prefill_enabled',true,Request::view_fields( array_keys( self::REQUEST_FIELDS ) ) ) ? array( 'staff' => $staff, 'location' => $location ) : apply_filters( 'abientot_admin_booking_prefill', array( 'staff' => $staff, 'location' => $location ), array( 'service_id' => (int) Request::view( 'service_id', 'int' ), 'staff_id' => $creation_prefill ? (int) Request::view( 'staff_id', 'int' ) : 0, 'location_id' => $creation_prefill ? (int) Request::view( 'location_id', 'int' ) : 0 ) ); if ( ! is_array( $choice ) || ! is_array( $choice['staff'] ?? null ) || ! is_array( $choice['location'] ?? null ) ) { throw new \DomainException( 'booking_prefill' ); } $staff = $choice['staff']; $location = $choice['location']; } catch ( \Throwable $error ) { echo '

' . esc_html__( 'The booking form is not fully configured yet.','abientot' ) . '

'; return; } } $prefill = ! $booking && ( ! empty( Request::view( 'start_local' ) ) || ! empty( Request::view( 'service_id' ) ) || ! empty( Request::view( 'customer_id' ) ) ); $active_date = $this->active_booking_date( $booking, $prefill ); if ( $panel_only ) { $this->render_booking_panel( $booking, $services, $statuses, $staff, $location, $active_date ); return; } $this->notice(); $view_today = wp_date( 'Y-m-d', null, DateTimeHelper::business_timezone() ); $view = ''; if ( '' === $filters['search'] && ! $filters['service_id'] && ! $filters['staff_id'] && '' === $filters['payment_status'] ) { if ( ! $filters['status_id'] ) { $view = $view_today === $filters['date_from'] ? ( $view_today === $filters['date_to'] ? 'today' : ( '' === $filters['date_to'] ? 'upcoming' : '' ) ) : ( '' === $filters['date_from'] && '' === $filters['date_to'] ? 'all' : '' ); } elseif ( '' === $filters['date_from'] && '' === $filters['date_to'] ) { $view = 'pending'; } } $pressed = static fn( string $mode ): string => ' aria-pressed="' . ( $mode === $view ? 'true' : 'false' ) . '"'; ?>
render_filters( $filters, $services, $statuses ); ?>

render_bookings_table( $bookings, $statuses, $services ); ?> render_pagination( $total, $limit, $page_number, $filters ); ?>
render_booking_panel( $booking, $services, $statuses, $staff, $location, $active_date, $prefill ); ?>
is_panel_request() ) { return; } // Forms in the panel must return to the real page, not to this request. $_SERVER['REQUEST_URI'] = remove_query_arg( 'abientot_panel' ); nocache_headers(); header( 'Content-Type: text/html; charset=' . get_option( 'blog_charset' ) ); $this->render_bookings(); exit; } private function is_panel_request(): bool { return 'abientot-bookings' === Request::view( 'page' ) && '1' === Request::view( 'abientot_panel' ) && (int) Request::view( 'booking_id', 'int' ) > 0; } private function render_booking_panel( ?array $booking, array $services, array $statuses, array $staff, array $location, string $active_date, bool $prefill = false ): void { ?> __( 'You do not have permission to manage bookings.', 'abientot' ) ), 403 ); } $this->acquire_booking_lock(); $id = isset( $_POST['booking_id'] ) ? absint( $_POST['booking_id'] ) : 0; $field = isset( $_POST['field'] ) ? sanitize_key( wp_unslash( $_POST['field'] ) ) : ''; $value = isset( $_POST['value'] ) ? sanitize_text_field( wp_unslash( $_POST['value'] ) ) : ''; $booking = $this->appointments->find_for_admin( $id ); if ( ! $booking ) { wp_send_json_error( array( 'message' => __( 'Booking not found.', 'abientot' ) ), 404 ); } if ( 'status_id' === $field ) { $status = $this->statuses->find( absint( $value ) ); if ( ! $status ) { wp_send_json_error( array( 'message' => __( 'Invalid booking status.', 'abientot' ) ), 400 ); } $this->validate_status_transition( $booking, $status ); $changes = array( 'status_id' => (int) $status['id'], 'cancelled_at' => 'cancelled' === (string) $status['slug'] ? gmdate( 'Y-m-d H:i:s' ) : null ); $event = 'status_changed'; $message = sprintf( 'Booking status changed to %s.', (string) $status['label'] ); $event_context = array( 'status' => (string) $status['slug'] ); $notification = (string) $status['slug'] !== (string) $booking['status_slug'] && in_array( (string) $status['slug'], array( 'confirmed', 'cancelled', 'completed' ), true ) ? (string) $status['slug'] : ''; } elseif ( 'payment_status' === $field ) { $value = sanitize_key( $value ); if ( ! in_array( $value, array( 'not_required', 'unpaid', 'paid' ), true ) ) { wp_send_json_error( array( 'message' => __( 'Invalid payment status.', 'abientot' ) ), 400 ); } $changes = array( 'payment_status' => $value ); $event = 'payment_status_changed'; $message = sprintf( 'Manual payment status changed to %s.', $this->label_from_slug( $value ) ); $event_context = array( 'payment_status' => $value ); } elseif ( 'service_id' === $field ) { $service = $this->services->find( absint( $value ) ); $status = $this->statuses->find( (int) $booking['status_id'] ); $staff = \Abientot\Support\BookingStaff::resolve( 'fixed', $booking, $service ? (int) $service['id'] : 0 ); $location = $this->locations->find( (int) $booking['location_id'] ) ?: array( 'id' => 0 ); $timezone = $this->timezone_for_booking( $staff, $location ); $start_local = $this->local_input_value( (string) $booking['start_utc'], $timezone->getName() ); if ( ! $service || ! $status || '' === $start_local ) { wp_send_json_error( array( 'message' => __( 'Invalid service.', 'abientot' ) ), 400 ); } $slot = $this->validated_slot( $service, $staff, $location, $status, $start_local, $booking, $timezone ); if ( ! $slot['ok'] ) { wp_send_json_error( array( 'message' => $this->notice_message( (string) $slot['notice'] ) ), 400 ); } $changes = array( 'service_id' => (int) $service['id'], 'end_utc' => (string) $slot['end_utc'], 'duration_minutes' => max( 1, (int) round( ( strtotime( $slot['end_utc'] . ' UTC' ) - strtotime( $slot['start_utc'] . ' UTC' ) ) / 60 ) ), ); $event = 'service_changed'; $message = sprintf( 'Booking service changed to %s.', (string) $service['name'] ); $event_context = array( 'service_id' => (int) $service['id'] ); } else { wp_send_json_error( array( 'message' => __( 'Invalid quick edit field.', 'abientot' ) ), 400 ); } if ( ! $this->persist_booking_change( $booking, $changes, $event, $message, $event_context ) ) { wp_send_json_error( array( 'message' => __( 'The booking could not be saved.', 'abientot' ) ), 500 ); } if ( ! empty( $notification ) ) { $this->notify_after_unlock( fn() => $this->emails->send_booking_event( $id, $notification ) ); } $updated = $this->appointments->find_for_admin( $id ); wp_send_json_success( array( 'message' => __( 'Booking updated.', 'abientot' ), 'booking' => $updated ? $this->booking_payload( $updated ) : null, ) ); } public function ajax_slots(): void { check_ajax_referer( 'abientot_admin_booking_slots', 'nonce' ); if ( ! current_user_can( 'abientot_manage_bookings' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to manage bookings.', 'abientot' ) ), 403 ); } $service_id = isset( $_POST['service_id'] ) ? absint( $_POST['service_id'] ) : 0; $date = isset( $_POST['date'] ) ? sanitize_text_field( wp_unslash( $_POST['date'] ) ) : ''; $booking_id = isset( $_POST['booking_id'] ) ? absint( $_POST['booking_id'] ) : 0; $service = $this->services->find( $service_id ); $existing = $booking_id > 0 ? $this->appointments->find_for_admin( $booking_id ) : null; try { $override = apply_filters( 'abientot_admin_booking_slots_override',null,Request::post_fields( 'abientot_admin_booking_slots', self::REQUEST_FIELDS, 'nonce' ),$existing ); } catch ( \Throwable $error ) { wp_send_json_error( array('message' => __( 'Choose an available time.','abientot' )),400 ); } if ( $override !== null ) { if ( ! is_array( $override ) ) { wp_send_json_error( array('message' => __( 'Choose an available time.','abientot' )),400 ); } wp_send_json_success( array( 'slots' => array_map( static fn(array $slot):array=>array( 'startLocal' => str_replace( ' ','T',substr( (string) $slot['start_local'],0,16 ) ), 'label' => DateTimeHelper::format_local_time( (string) $slot['start_local'] ), 'quote' => $slot['quote'] ?? null, 'resourceId' => (int) ( $slot['resource_choice']['resource_id'] ?? 0 ) ), $override )) ); } if ( $existing && ( new \Abientot\Repositories\ResourceBookingRepository() )->snapshot( (int) $existing['id'] ) ) { $current = ( new DateTimeImmutable( $existing['start_utc'],DateTimeHelper::utc_timezone() ) )->setTimezone( new DateTimeZone( $existing['timezone'] ) ); wp_send_json_success( array( 'slots' => $current->format( 'Y-m-d' ) === $date ? array( array( 'startLocal' => $current->format( 'Y-m-d\TH:i' ), 'label' => DateTimeHelper::format_local_time( $current->format( 'Y-m-d H:i:s' ) ), 'quote' => array( 'price_cents' => (int) $existing['price_cents'], 'currency' => $existing['currency'], 'duration_minutes' => (int) $existing['duration_minutes'] ) )) : array()) ); } $staff = \Abientot\Support\BookingStaff::resolve( 'admin', $existing ); $location = \Abientot\Support\BookingLocation::resolve( 'admin', $existing ); if ( (int) $location['id'] <= 0 ) { wp_send_json_error( array( 'message' => __( 'Choose an available location.', 'abientot' ) ), 400 ); } if ( (int) $staff['id'] <= 0 ) { wp_send_json_error( array( 'message' => __( 'Choose an available staff member.', 'abientot' ) ), 400 ); } if ( ! $service || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) { wp_send_json_error( array( 'message' => __( 'Choose a valid service and date.', 'abientot' ) ), 400 ); } $slots = $this->availability->generate_slots( array( 'service_id' => $service_id, 'staff_id' => (int) $staff['id'], 'location_id' => (int) $location['id'], 'date_from' => $date, 'date_to' => $date, 'exclude_appointment_id' => $booking_id > 0 ? $booking_id : null, 'ignore_minimum_notice' => true, ) ); // Retained history stays editable even when archived resources have no new slots. if ( $existing && (int) $existing['service_id'] === $service_id && (int) $existing['staff_id'] === (int) $staff['id'] && (int) $existing['location_id'] === (int) $location['id'] ) { $current = $this->local_input_value( (string) $existing['start_utc'], $this->timezone_for_booking( $staff, $location )->getName() ); if ( substr( $current, 0, 10 ) === $date && ! in_array( str_replace( 'T', ' ', $current ) . ':00', array_column( $slots, 'start_local' ), true ) ) { $slots[] = array( 'start_local' => str_replace( 'T', ' ', $current ) . ':00' ); } } wp_send_json_success( array( 'slots' => array_map( function ( array $slot ): array { return array( 'startLocal' => str_replace( ' ', 'T', substr( (string) $slot['start_local'], 0, 16 ) ), 'label' => DateTimeHelper::format_local_time( (string) $slot['start_local'] ), ); }, $slots ), ) ); } public function ajax_booking_change_preview(): void { check_ajax_referer( 'abientot_booking_change_confirmation', 'nonce' ); if ( ! current_user_can( 'abientot_manage_bookings' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to manage bookings.', 'abientot' ) ), 403 ); } $id = isset( $_POST['booking_id'] ) ? absint( $_POST['booking_id'] ) : 0; $event = isset( $_POST['event'] ) ? sanitize_key( wp_unslash( $_POST['event'] ) ) : 'rescheduled'; $start_local = isset( $_POST['start_local'] ) ? sanitize_text_field( wp_unslash( $_POST['start_local'] ) ) : ''; $booking = $this->appointments->find_for_admin( $id ); if ( ! $booking || ! in_array( $event, array( 'rescheduled', 'cancelled' ), true ) ) { wp_send_json_error( array( 'message' => __( 'Booking not found.', 'abientot' ) ), 404 ); } $start_utc = ''; $end_utc = ''; if ( 'rescheduled' === $event && '' !== $start_local ) { $slot = $this->slot_for_existing_booking( $booking, $start_local ); if ( ! $slot['ok'] ) { wp_send_json_error( array( 'message' => $this->notice_message( (string) $slot['notice'] ) ), 400 ); } $start_utc = (string) $slot['start_utc']; $end_utc = (string) $slot['end_utc']; } $preview = $this->emails->preview_booking_event( $id, $event, $start_utc, $end_utc ); wp_send_json_success( array( 'subject' => $preview['subject'], 'body' => $preview['body'], 'customer' => array( 'name' => (string) $booking['customer_name'], 'email' => (string) $booking['customer_email'], 'phone' => (string) $booking['customer_phone'], ), ) ); } public function ajax_reschedule_booking(): void { check_ajax_referer( 'abientot_booking_change_confirmation', 'nonce' ); if ( ! current_user_can( 'abientot_manage_bookings' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to manage bookings.', 'abientot' ) ), 403 ); } $this->input = Input::post( 'abientot_booking_change_confirmation', 'nonce' ); $this->acquire_booking_lock(); $id = $this->input->int( 'booking_id' ); $start_local = $this->input->text( 'start_local' ); $message = $this->email_body_post( 'message' ); $booking = $this->appointments->find_for_admin( $id ); if ( ! $booking ) { wp_send_json_error( array( 'message' => __( 'Booking not found.', 'abientot' ) ), 404 ); } $slot = $this->slot_for_existing_booking( $booking, $start_local ); if ( ! $slot['ok'] ) { wp_send_json_error( array( 'message' => $this->notice_message( (string) $slot['notice'] ) ), 400 ); } if ( ! $this->persist_booking_change( $booking, array( 'start_utc' => (string) $slot['start_utc'], 'end_utc' => (string) $slot['end_utc'] ), 'booking_rescheduled', __( 'Booking rescheduled by an administrator.', 'abientot' ) ) ) { wp_send_json_error( array( 'message' => __( 'The booking could not be saved.', 'abientot' ) ), 500 ); } $this->notify_after_unlock( fn() => $this->emails->send_booking_event( $id, 'rescheduled', $message ) ); $updated = $this->appointments->find_for_admin( $id ); wp_send_json_success( array( 'message' => __( 'Booking updated.', 'abientot' ), 'booking' => $updated ? $this->booking_payload( $updated ) : null, ) ); } public function save_booking(): void { check_admin_referer( 'abientot_save_booking' ); $this->authorize( 'abientot_manage_bookings' ); $this->input = Input::post( 'abientot_save_booking' ); $result = $this->save_booking_record(); if ( is_wp_error( $result ) ) { $data = $result->get_error_data(); if ( isset( $_SERVER['HTTP_X_ABIENTOT_FEEDBACK'] ) && '1' === sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_ABIENTOT_FEEDBACK'] ) ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ?: $this->notice_message( $result->get_error_code() ), 'notice' => $result->get_error_code(), 'recovery' => is_array( $data ) && is_array( $data['recovery'] ?? null ) ? $data['recovery'] : null ), 400 ); } $this->redirect( $result->get_error_code(), is_array( $data ) ? (int) ( $data['booking_id'] ?? 0 ) : 0 ); } $this->redirect( 'saved', (int) ( $result['booking_id'] ?? 0 ) ); } public function ajax_save_booking(): void { if ( ! check_ajax_referer( 'abientot_save_booking', '_wpnonce', false ) ) { wp_send_json_error( array( 'message' => __( 'The booking could not be saved.', 'abientot' ), 'notice' => 'invalid-booking' ), 403 ); } if ( ! current_user_can( 'abientot_manage_bookings' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to manage bookings.', 'abientot' ) ), 403 ); } $this->input = Input::post( 'abientot_save_booking' ); $result = $this->save_booking_record(); if ( is_wp_error( $result ) ) { $error_data = $result->get_error_data(); wp_send_json_error( array( 'message' => $result->get_error_message() ?: $this->notice_message( $result->get_error_code() ), 'notice' => $result->get_error_code(), 'recovery' => is_array( $error_data ) && is_array( $error_data['recovery'] ?? null ) ? $error_data['recovery'] : null, ), 400 ); } wp_send_json_success( array( 'message' => __( 'Booking saved.', 'abientot' ), 'booking' => $result['booking'] ?? null, ) ); } /** * @return array|\WP_Error */ private function save_booking_record() { global $wpdb; $this->acquire_booking_lock(); $this->pending_notifications = array(); $started = false; try { \Abientot\Support\TransactionGuard::prepare_write(); if ( false === $wpdb->query( 'START TRANSACTION' ) ) { throw new Exception( 'Transaction unavailable.' ); } $started = true; $this->booking_transaction_active = true; $persist = function ( array $overrides = array() ) { if ( ! $this->booking_transaction_active || ! BookingLock::is_held() ) { throw new \RuntimeException( 'booking_transaction_owner' ); } $previous = $this->overrides; $this->overrides = array_merge( $previous, $overrides ); try { return $this->persist_booking_record(); } finally { $this->overrides = $previous; } }; $replay = function ( int $id ): array { if ( ! $this->booking_transaction_active || ! BookingLock::is_held() ) { throw new \RuntimeException( 'booking_transaction_owner' ); } $row = $this->appointments->find_for_admin( $id ); if ( ! $row ) { throw new \DomainException( 'booking_missing' ); } return array( 'booking_id' => $id, 'booking' => $this->booking_payload( $row ), '_event_after' => $row ); }; $result = apply_filters( 'abientot_admin_booking_persist_operation', null, $this->request_fields(), $persist, $replay, fn(): bool => $this->booking_transaction_active && BookingLock::is_held() ); if ( $result === null ) { $result = $persist(); } if ( ! is_wp_error( $result ) && ( ! is_array( $result ) || empty( $result['booking_id'] ) || ! is_array( $result['booking'] ?? null ) ) ) { throw new \RuntimeException( 'booking_operation_result' ); } if ( is_wp_error( $result ) ) { $wpdb->query( 'ROLLBACK' ); return $result; } if ( false === $wpdb->query( 'COMMIT' ) ) { throw new Exception( 'Commit failed.' ); } $started = false; $this->booking_transaction_active = false; } catch ( \Throwable $exception ) { if ( $started ) { $wpdb->query( 'ROLLBACK' ); } if ( $exception instanceof \InvalidArgumentException && in_array( $exception->getMessage(),array('abientot_invalid_price','money_precision','money_input'),true ) ) { return new \WP_Error( 'invalid-price',$this->notice_message( 'invalid-price' ) ); } $failure = apply_filters( 'abientot_admin_booking_operation_error', null, $exception ); return is_wp_error( $failure ) ? $failure : new \WP_Error( 'invalid-booking', __( 'The booking could not be saved.', 'abientot' ) ); } finally { $this->booking_transaction_active = false; $this->release_booking_lock(); } if ( empty( $result['_replayed'] ) ) { foreach ( array_merge( array( $result ), $result['_additional_bookings'] ?? array() ) as $saved ) { \Abientot\Support\BookingEvents::dispatch( empty( $saved['_event_before'] ) ? 'created' : 'updated', (int) $saved['booking_id'], $saved['_event_before'] ?? null, $saved['_event_after'] ?? array() ); } } foreach ( $this->pending_notifications as $notification ) { $this->notify_after_unlock( function () use ( $notification ): void { if ( 'created' === $notification[1] ) { $this->emails->send_booking_created( $notification[0] ); } else { $this->emails->send_booking_event( ...$notification ); } } ); } return $result; } /** Persist all related data in the caller's transaction. */ private function persist_booking_record() { $id = $this->int_post( 'booking_id' ); $existing = $id > 0 ? $this->appointments->find_for_admin( $id ) : null; if ( $id && ! $existing ) { throw new \DomainException( 'booking_missing' ); } $resolved = apply_filters( 'abientot_admin_booking_resolution',null,$this->request_fields(),$existing,fn():bool=>$this->booking_transaction_active && BookingLock::is_held() ); if ( $resolved !== null && ( ! is_array( $resolved ) || ! is_array( $resolved['service'] ?? null ) || ! is_array( $resolved['staff'] ?? null ) || ! is_array( $resolved['location'] ?? null ) || ! is_array( $resolved['slot'] ?? null ) || ! ( ( $resolved['timezone'] ?? null ) instanceof DateTimeZone ) || ! is_array( $resolved['quote'] ?? null ) ) ) { throw new \DomainException( 'booking_resolution' ); } $service = $resolved['service'] ?? $this->services->find( $this->int_post( 'service_id' ) ); $staff = $resolved['staff'] ?? \Abientot\Support\BookingStaff::resolve( 'admin', $existing ); $location = $resolved['location'] ?? \Abientot\Support\BookingLocation::resolve( 'admin', $existing ); $status = $this->statuses->find( $this->int_post( 'status_id' ) ); if ( ! $service || ! $status ) { return new \WP_Error( 'invalid-booking', $this->notice_message( 'invalid-booking' ), array( 'booking_id' => $id ) ); } $start_local = $this->datetime_post( 'start_local' ); if ( '' === $start_local ) { return new \WP_Error( 'invalid-date', $this->notice_message( 'invalid-date' ), array( 'booking_id' => $id ) ); } $timezone = $resolved['timezone'] ?? ( $existing && ( new \Abientot\Repositories\ResourceBookingRepository() )->snapshot( (int) $existing['id'] ) ? new DateTimeZone( $existing['timezone'] ) : $this->timezone_for_booking( $staff, $location ) ); $preserve_timezone = $existing && (int) $existing['service_id'] === (int) $service['id'] && (int) $existing['staff_id'] === (int) $staff['id'] && (int) $existing['location_id'] === (int) $location['id'] && $start_local === $this->local_input_value( (string) $existing['start_utc'], $timezone->getName() ); $slot = $resolved['slot'] ?? $this->validated_slot( $service, $staff, $location, $status, $start_local, $existing, $timezone ); if ( ! $slot['ok'] ) { return new \WP_Error( (string) $slot['notice'], $this->notice_message( (string) $slot['notice'] ), array( 'booking_id' => $id ) ); } $first_name = $this->text_post( 'first_name' ); $last_name = $this->text_post( 'last_name' ); $display_name = trim( $first_name . ' ' . $last_name ); $email = (string) ( $this->field( 'email', 'email' ) ?? '' ); $phone = $this->text_post( 'phone' ); $selected_customer_id = $this->int_post( 'selected_customer_id' ); $selected_customer = ! $existing && $selected_customer_id > 0 ? $this->customers->find( $selected_customer_id ) : null; $previous_phone = $existing ? (string) ( $existing['customer_phone'] ?? '' ) : ( $selected_customer ? (string) ( $selected_customer['phone'] ?? '' ) : null ); if ( '' !== $phone && $phone !== $previous_phone ) { try { $country = $this->text_post( 'phone_country' ); $phone = \Abientot\Support\Phone::normalize( $phone, $country ); } catch ( \DomainException $error ) { return new \WP_Error( 'invalid-phone', $this->notice_message( 'invalid-phone' ), array( 'booking_id' => $id ) ); } } if ( '' === $display_name && '' === $email && '' === $phone ) { return new \WP_Error( 'missing-customer', $this->notice_message( 'missing-customer' ), array( 'booking_id' => $id ) ); } if ( '' === $display_name ) { $display_name = $email ?: $phone; } $customer_id = $existing ? (int) $existing['customer_id'] : ( $selected_customer ? (int) $selected_customer['id'] : $this->customers->find_or_create_for_booking( array( 'first_name' => $first_name, 'last_name' => $last_name, 'display_name' => $display_name, 'email' => $email, 'phone' => $phone, ) ) ); if ( $customer_id <= 0 ) { throw new Exception( 'Customer could not be saved.' ); } if ( $existing || $selected_customer ) { $customer_before = $this->customers->find( $customer_id ); if ( ! $this->customers->update( $customer_id, array( 'first_name' => $first_name, 'last_name' => $last_name, 'display_name' => $display_name, 'email' => $email, 'phone' => $phone, 'updated_at' => gmdate( 'Y-m-d H:i:s' ), ) ) ) { throw new Exception( 'Customer could not be updated.' ); } if ( ! \Abientot\Support\ActivityObserver::customer_changes( $customer_id, $customer_before ?? array(), array( 'first_name' => $first_name, 'last_name' => $last_name, 'email' => $email, 'phone' => $phone ), (int) $location['id'] ) ) { throw new Exception( 'Activity could not be saved.' ); } } $now = gmdate( 'Y-m-d H:i:s' ); $data = array( 'customer_id' => $customer_id, 'service_id' => (int) $service['id'], 'staff_id' => (int) $staff['id'], 'location_id' => (int) $location['id'], 'status_id' => (int) $status['id'], 'start_utc' => (string) $slot['start_utc'], 'end_utc' => (string) $slot['end_utc'], 'timezone' => $preserve_timezone ? (string) $existing['timezone'] : $timezone->getName(), 'duration_minutes' => max( 1, (int) round( ( strtotime( $slot['end_utc'] . ' UTC' ) - strtotime( $slot['start_utc'] . ' UTC' ) ) / 60 ) ), 'price_cents' => $resolved['quote']['price_cents'] ?? $this->price_cents_post( 'price' ), 'currency' => $resolved['quote']['currency'] ?? $this->currency_post( 'currency' ), 'payment_status' => $this->choice_post( 'payment_status', array( 'not_required', 'unpaid', 'paid' ), 'not_required' ), 'source' => $existing ? (string) $existing['source'] : 'admin', 'customer_fields_json' => wp_json_encode( $this->merge_customer_snapshot( $existing, $this->admin_customer_field_snapshot( $first_name, $last_name, $email, $phone, $this->textarea_post( 'customer_note' ) ) ) ), 'customer_note' => $this->textarea_post( 'customer_note' ), 'internal_note' => $this->textarea_post( 'internal_note' ), 'cancelled_at' => 'cancelled' === (string) $status['slug'] ? $now : null, 'updated_at' => $now, ); if ( ! $existing && (int) $data['price_cents'] === 0 && empty( $resolved['quote']['price_open'] ) ) { $data['payment_status'] = 'not_required'; } if ( $existing && $this->payments->has_online_records( $id ) ) { foreach ( array( 'price_cents', 'currency', 'payment_status' ) as $field ) { if ( (string) $data[ $field ] !== (string) $existing[ $field ] ) { return new \WP_Error( 'online-payment-managed', __( 'This booking has online payment history. Reconcile its payment before changing financial details.', 'abientot' ) ); } } } if ( $existing ) { $was_rescheduled = (string) $existing['start_utc'] !== (string) $data['start_utc']; $was_confirmed = 'confirmed' !== (string) $existing['status_slug'] && 'confirmed' === (string) $status['slug']; $was_cancelled = 'cancelled' !== (string) $existing['status_slug'] && 'cancelled' === (string) $status['slug']; $was_completed = 'completed' !== (string) $existing['status_slug'] && 'completed' === (string) $status['slug']; if ( ! $this->appointments->update( $id, $data ) ) { throw new Exception( 'Appointment could not be updated.' ); } \Abientot\Support\BookingConstraints::apply( array_merge( $existing, $data ), $existing, fn()=> $this->booking_transaction_active ); if ( ! $this->payments->has_online_records( $id ) && ! $this->payments->mark_manual_status( $id, (string) $data['payment_status'], (int) $data['price_cents'], (string) $data['currency'] ) ) { throw new Exception( 'Payment could not be saved.' ); } $other_changed = false; foreach ( array('service_id','currency','customer_note','internal_note')as$field ) { if ( (string) ( $existing[ $field ] ?? '' ) !== (string) ( $data[ $field ] ?? '' ) ) { $other_changed = true; break; } } if ( $other_changed && ! $this->activity->record_booking_event( $id, 'booking_updated', __( 'Booking updated by an administrator.', 'abientot' ) ) ) { throw new Exception( 'Activity could not be saved.' ); } if ( ! \Abientot\Support\ActivityObserver::booking_changes( $id,$existing,$data ) ) { throw new Exception( 'Activity could not be saved.' ); } $custom_message = $this->email_body_post( 'abientot_customer_message' ); if ( $was_rescheduled && ! $was_confirmed ) { $this->pending_notifications[] = array( $id, 'rescheduled', $custom_message ); } if ( $was_confirmed ) { $this->pending_notifications[] = array( $id, 'confirmed' ); } if ( $was_cancelled ) { $this->pending_notifications[] = array( $id, 'cancelled', $custom_message ); } if ( $was_completed ) { $this->pending_notifications[] = array( $id, 'completed' ); } $updated = $this->appointments->find_for_admin( $id ); if ( ! $updated ) { return new \WP_Error( 'invalid-booking', $this->notice_message( 'invalid-booking' ), array( 'booking_id' => $id ) ); } return array( 'booking_id' => $id, 'booking' => $this->booking_payload( $updated ), '_event_before' => $existing, '_event_after' => $updated, ); } $data['created_at'] = $now; $id = $this->appointments->insert( $data ); if ( $id <= 0 ) { throw new Exception( 'Appointment could not be saved.' ); } do_action( 'abientot_booking_snapshot_persist',array_merge( $data,array('id' => $id) ),$service,fn():bool=>$this->booking_transaction_active && BookingLock::is_held() ); \Abientot\Support\BookingConstraints::apply( array_merge( $data, array( 'id' => $id ) ), null, fn()=> $this->booking_transaction_active ); if ( ! $this->payments->mark_manual_status( $id, (string) $data['payment_status'], (int) $data['price_cents'], (string) $data['currency'] ) ) { throw new Exception( 'Payment could not be saved.' ); } if ( ! $this->activity->record_booking_event( $id, 'booking_created', __( 'Booking created by an administrator.', 'abientot' ) ) ) { throw new Exception( 'Activity could not be saved.' ); } $this->pending_notifications[] = array( $id, 'created' ); $created = $this->appointments->find_for_admin( $id ); if ( ! $created ) { return new \WP_Error( 'invalid-booking', $this->notice_message( 'invalid-booking' ), array( 'booking_id' => $id ) ); } return array( 'booking_id' => $id, 'booking' => $this->booking_payload( $created ), '_event_before' => null, '_event_after' => $created, ); } public function change_status(): void { check_admin_referer( 'abientot_booking_status' ); $this->authorize( 'abientot_manage_bookings' ); $query = Input::query( 'abientot_booking_status' ); $this->acquire_booking_lock(); $id = $query->int( 'booking_id' ); $slug = $query->key( 'status' ); $booking = $this->appointments->find_for_admin( $id ); $status = $this->statuses->find_by_slug( $slug ); if ( ! $booking || ! $status || ! in_array( $slug, array( 'cancelled', 'completed', 'no_show' ), true ) ) { $this->redirect( 'invalid-booking', $id ); } $this->validate_status_transition( $booking, $status ); $changes = array( 'status_id' => (int) $status['id'], 'cancelled_at' => 'cancelled' === $slug ? gmdate( 'Y-m-d H:i:s' ) : null ); if ( ! $this->persist_booking_change( $booking, $changes, 'status_changed', sprintf( 'Booking status changed to %s.', (string) $status['label'] ), array( 'status' => $slug ) ) ) { $this->redirect( 'invalid-booking', $id ); } if ( $slug !== (string) $booking['status_slug'] && in_array( $slug, array( 'cancelled', 'completed' ), true ) ) { $this->release_booking_lock(); $this->notify_after_unlock( fn() => $this->emails->send_booking_event( $id, $slug ) ); } $this->redirect( 'saved', $id ); } /** Persist a locked booking mutation and its dependent records before notifying. */ private function persist_booking_change( array $booking, array $changes, string $event, string $message, array $context = array() ): bool { global $wpdb; $id = (int) $booking['id']; $started = false; try { \Abientot\Support\TransactionGuard::prepare_write(); if ( false === $wpdb->query( 'START TRANSACTION' ) ) { throw new Exception( 'Transaction could not start.' ); } $started = true; $this->booking_transaction_active = true; $changes['updated_at'] = gmdate( 'Y-m-d H:i:s' ); if ( ! $this->appointments->update( $id, $changes ) ) { throw new Exception( 'Appointment could not be saved.' ); } \Abientot\Support\BookingConstraints::apply( array_merge( $booking, $changes ), $booking, fn()=> $this->booking_transaction_active ); if ( isset( $changes['payment_status'] ) && $this->payments->has_online_records( $id ) && $changes['payment_status'] !== $booking['payment_status'] ) { throw new Exception( 'Online payment requires reconciliation.' ); } if ( isset( $changes['payment_status'] ) && ! $this->payments->has_online_records( $id ) && ! $this->payments->mark_manual_status( $id, $changes['payment_status'], (int) $booking['price_cents'], (string) $booking['currency'] ) ) { throw new Exception( 'Payment could not be saved.' ); } if ( ! \Abientot\Support\ActivityObserver::booking_changes( $id,$booking,$changes ) ) { throw new Exception( 'Activity could not be saved.' ); } if ( false === $wpdb->query( 'COMMIT' ) ) { throw new Exception( 'Transaction could not commit.' ); } $started = false; \Abientot\Support\BookingEvents::dispatch( $event === 'booking_rescheduled' ? 'rescheduled' : $event, $id, $booking, array_merge( $booking, $changes ) ); return true; } catch ( \Throwable $exception ) { if ( $started ) { $wpdb->query( 'ROLLBACK' ); } return false; } finally { $this->booking_transaction_active = false; $this->release_booking_lock(); } } public function mark_paid(): void { check_admin_referer( 'abientot_mark_booking_paid' ); $this->authorize( 'abientot_manage_bookings' ); $query = Input::query( 'abientot_mark_booking_paid' ); $this->change_payment_status( 'paid', $query->int( 'booking_id' ) ); } public function update_payment_status(): void { check_admin_referer( 'abientot_update_booking_payment' ); $this->authorize( 'abientot_manage_bookings' ); $query = Input::query( 'abientot_update_booking_payment' ); $this->change_payment_status( $query->key( 'payment_status' ), $query->int( 'booking_id' ) ); } /** Shared by the payment handlers above, after their nonce and capability checks. */ private function change_payment_status( string $status, int $id ): void { $this->acquire_booking_lock(); $booking = $this->appointments->find_for_admin( $id ); if ( ! $booking || ! in_array( $status, array( 'not_required', 'unpaid', 'paid' ), true ) ) { $this->redirect( 'invalid-booking' ); } if ( ! $this->persist_booking_change( $booking, array( 'payment_status' => $status ), 'payment_status_changed', sprintf( 'Manual payment status changed to %s.', $this->label_from_slug( $status ) ), array( 'payment_status' => $status ) ) ) { $this->redirect( 'invalid-booking', $id ); } $this->redirect( 'saved', $id ); } public function export_csv(): void { check_admin_referer( 'abientot_export_bookings' ); $this->authorize( 'abientot_manage_bookings' ); $rows = $this->appointments->iterate_for_export( $this->filters_from_request() ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=abientot-bookings-' . gmdate( 'Y-m-d' ) . '.csv' ); $out = fopen( 'php://output', 'w' ); if ( false === $out ) { exit; } fputcsv( $out, array( 'ID', 'Start', 'End', 'Customer', 'Email', 'Phone', 'Service', 'Staff', 'Location', 'Status', 'Payment', 'Price', 'Currency', 'Source', 'Created' ) ); foreach ( $rows as $row ) { fputcsv( $out, array( $row['id'], $this->local_datetime_label( (string) $row['start_utc'], (string) $row['timezone'] ), $this->local_datetime_label( (string) $row['end_utc'], (string) $row['timezone'] ), $this->csv_cell( (string) $row['customer_name'] ), $this->csv_cell( (string) $row['customer_email'] ), $this->csv_cell( (string) $row['customer_phone'] ), $this->csv_cell( (string) $row['service_name'] ), $this->csv_cell( (string) $row['staff_name'] ), $this->csv_cell( (string) $row['location_name'] ), $this->csv_cell( (string) $row['status_label'] ), $this->csv_cell( (string) $row['payment_status'] ), $this->format_price( (int) $row['price_cents'] ), $this->csv_cell( (string) $row['currency'] ), $this->csv_cell( (string) $row['source'] ), $this->csv_cell( (string) $row['created_at'] ), ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the authorized php://output CSV response stream; WP_Filesystem does not manage response streams. fclose( $out ); exit; } private function render_filters( array $filters, array $services, array $statuses ): void { ?>
snapshot( (int) $booking['id'] ) : null; if ( $retained_identity ) { $current_service = $this->services->find( (int) $booking['service_id'] ); $services = $current_service ? array($current_service) : array(); } $prefill = $booking ? array() : $this->prefill_from_request(); $service_id = (int) ( $booking['service_id'] ?? $prefill['service_id'] ); $service = $service_id > 0 ? $this->services->find( $service_id ) : null; $price = $booking ? (int) $booking['price_cents'] : (int) ( $service['price_cents'] ?? 0 ); $payment_plan = $booking ? ( new \Abientot\Repositories\PaymentPlanRepository() )->plan( (int) $booking['id'] ) : null; $currency = $booking ? (string) $booking['currency'] : (string) ( $service['currency'] ?? Settings::values()['currency'] ); $start = $booking ? \Abientot\Support\BookingStaff::edit_start( $booking ) : (string) $prefill['start_local']; $date = '' !== $start ? substr( $start, 0, 10 ) : ''; $date_label = '' !== $date ? $this->local_date_label( $date ) : ''; ?>
drawer_section( __( 'Booking details','abientot' ),__( 'Choose what is being booked and who is responsible.','abientot' ),'clipboard' ); ?> > drawer_section( __( 'Scheduling','abientot' ),__( 'Find the right date and available time.','abientot' ),'calendar-alt' ); ?> drawer_section( __( 'Status & payment','abientot' ),__( 'Keep the booking and payment up to date.','abientot' ),'yes-alt' ); ?> select_row( 'payment_status', __( 'Payment status', 'abientot' ), (string) ( $booking['payment_status'] ?? Settings::values()['default_payment_status'] ), array( 'not_required' => __( 'Not required', 'abientot' ), 'unpaid' => __( 'Unpaid', 'abientot' ), 'paid' => __( 'Paid', 'abientot' ) ), (bool) $payment_plan ); echo ''; $this->drawer_section( __( 'Customer','abientot' ),__( 'Contact details for this appointment.','abientot' ),'admin-users' ); echo ''; $this->input_row( 'first_name', __( 'First name', 'abientot' ), (string) ( $booking['first_name'] ?? $prefill['first_name'] ?? '' ) ); $this->input_row( 'last_name', __( 'Last name', 'abientot' ), (string) ( $booking['last_name'] ?? $prefill['last_name'] ?? '' ) ); $this->input_row( 'email', __( 'Email', 'abientot' ), (string) ( $booking['customer_email'] ?? $prefill['email'] ?? '' ), 'email' ); $this->input_row( 'phone', __( 'Phone', 'abientot' ), (string) ( $booking['customer_phone'] ?? $prefill['phone'] ?? '' ), 'tel' ); if ( ! $booking ) { ?> '; $this->drawer_section( __( 'Pricing & notes','abientot' ),__( 'Set amounts and add useful notes.','abientot' ),'tag' ); echo ''; $this->input_row( 'price', __( 'Price', 'abientot' ), $this->format_price( $price ), 'number', false, '0', '999999', '0.01', (bool) ( $payment_plan || $retained_identity ) ); $this->input_row( 'currency', __( 'Currency', 'abientot' ), $currency, 'text', true, '', '', '', (bool) ( $payment_plan || $retained_identity ) ); $this->textarea_row( 'customer_note', __( 'Customer note', 'abientot' ), (string) ( $booking['customer_note'] ?? '' ) ); $this->textarea_row( 'internal_note', __( 'Internal note', 'abientot' ), (string) ( $booking['internal_note'] ?? '' ) ); ?>

' . esc_html( $title ) . '

' . esc_html( $subtitle ) . '

'; } private function render_detail_panel( ?array $booking ): void { if ( ! $booking ) { echo '

' . esc_html__( 'Select a booking to view details and activity.', 'abientot' ) . '

'; return; } $activity = $this->activity->for_booking( (int) $booking['id'] ); ?>
local_datetime_label( (string) $booking['start_utc'], (string) $booking['timezone'] ) ); ?>
local_datetime_label( (string) $booking['start_utc'], (string) $booking['timezone'] ) ), array( 'a' => array( 'class' => true, 'href' => true, 'target' => true, 'rel' => true ) ) ); ?>
render_customer_field_snapshot( $booking ); ?>

$booking Booking row. */ private function render_customer_field_snapshot( array $booking ): void { $fields = $this->customer_field_snapshot( (string) ( $booking['customer_fields_json'] ?? '' ) ); if ( ! $fields ) { return; } ?>

> */ private function customer_field_snapshot( string $json ): array { $decoded = json_decode( $json, true ); if ( ! is_array( $decoded ) ) { return array(); } $fields = array(); foreach ( $decoded as $field ) { if ( ! is_array( $field ) || empty( $field['label'] ) ) { continue; } $fields[] = array( 'label' => sanitize_text_field( (string) $field['label'] ), 'type' => sanitize_key( (string) ( $field['type'] ?? 'text' ) ), 'value' => is_scalar( $field['value'] ?? '' ) ? sanitize_textarea_field( (string) $field['value'] ) : '', ); } return $fields; } /** * @return array> */ private function admin_customer_field_snapshot( string $first_name, string $last_name, string $email, string $phone, string $customer_note ): array { return array( array( 'key' => 'first_name', 'label' => __( 'First name', 'abientot' ), 'type' => 'text', 'required' => '0', 'value' => $first_name ), array( 'key' => 'last_name', 'label' => __( 'Last name', 'abientot' ), 'type' => 'text', 'required' => '0', 'value' => $last_name ), array( 'key' => 'email', 'label' => __( 'Email', 'abientot' ), 'type' => 'email', 'required' => '0', 'value' => $email ), array( 'key' => 'phone', 'label' => __( 'Phone', 'abientot' ), 'type' => 'tel', 'required' => '0', 'value' => $phone ), array( 'key' => 'customer_note', 'label' => __( 'Note', 'abientot' ), 'type' => 'textarea', 'required' => '0', 'value' => $customer_note ), ); } private function render_bookings_table( array $bookings, array $statuses, array $services ): void { if ( ! $bookings ) { echo '

' . esc_html__( 'No bookings found.', 'abientot' ) . '

'; return; } ?>
local_datetime_label( (string) $booking['start_utc'], (string) $booking['timezone'] ) ); ?> quick_service_select( $booking, $services ); ?>' . esc_html( (string) $booking['staff_name'] ) . ''; } ?> quick_status_select( $booking, $statuses ); ?> quick_payment_select( $booking ); ?> format_money( (int) $booking['price_cents'], (string) $booking['currency'] ) ); ?> $descriptor ) : ?>
$descriptor ) { if ( ! is_string( $key ) || '' === $key || sanitize_key( $key ) !== $key || ! is_array( $descriptor ) || ! is_string( $descriptor['label'] ?? null ) || ( isset( $descriptor['icon'] ) && ! is_string( $descriptor['icon'] ) ) ) { continue; } $actions[ $key ] = array( 'label' => $descriptor['label'], 'icon' => sanitize_html_class( $descriptor['icon'] ?? 'dashicons-admin-links' ) ); } return $actions; } private function quick_status_select( array $booking, array $statuses ): void { printf( ''; } private function quick_service_select( array $booking, array $services ): void { if ( ( new \Abientot\Repositories\ResourceBookingRepository() )->snapshot( (int) $booking['id'] ) ) { echo esc_html( (string) $booking['service_name'] ); return; } $select_id = 'abientot-quick-service-' . absint( $booking['id'] ); printf( ''; } private function quick_payment_select( array $booking ): void { if ( ! empty( $booking['has_payment_plan'] ) ) { echo esc_html( $this->label_from_slug( (string) $booking['payment_status'] ) ); return; } echo ''; } /** * @param array $row Joined booking row. * @return array */ private function booking_payload( array $row ): array { $start = new DateTimeImmutable( (string) $row['start_utc'], DateTimeHelper::utc_timezone() ); $end = new DateTimeImmutable( (string) $row['end_utc'], DateTimeHelper::utc_timezone() ); try { $timezone = new DateTimeZone( (string) $row['timezone'] ); } catch ( Exception $exception ) { $timezone = DateTimeHelper::business_timezone(); } return array( 'id' => (int) $row['id'], 'customerId' => (int) $row['customer_id'], 'serviceId' => (int) $row['service_id'], 'locationId' => (int) $row['location_id'], 'locationName' => \Abientot\Support\BookingLocation::show_relationships() ? (string) $row['location_name'] : '', 'staffName' => ( \Abientot\Support\BookingStaff::show_relationships() || \Abientot\Support\StaffFilter::enabled() ) ? (string) $row['staff_name'] : '', 'staffId' => (int) $row['staff_id'], 'showStaff' => \Abientot\Support\StaffFilter::enabled(), 'bookingKind' => (string) ( $row['booking_kind'] ?? 'service' ), 'resourceCategory' => (string) ( $row['resource_category'] ?? '' ), 'resourceCategoryId' => (int) ( $row['resource_category_id'] ?? 0 ), 'resourceLabel' => (string) ( $row['resource_label'] ?? '' ), 'resourceId' => (int) ( $row['resource_id'] ?? 0 ), 'partySize' => (int) ( $row['party_size'] ?? 1 ), 'rowActions' => self::row_actions( $row ), 'statusId' => (int) $row['status_id'], 'startUtc' => (string) $row['start_utc'], 'editStartLocal' => \Abientot\Support\BookingStaff::edit_start( $row ), 'endUtc' => (string) $row['end_utc'], 'updatedAt' => (string) $row['updated_at'], 'timezone' => $timezone->getName(), 'date' => $start->setTimezone( $timezone )->format( 'Y-m-d' ), 'day' => $start->setTimezone( $timezone )->format( 'j' ), 'time' => $start->setTimezone( $timezone )->format( 'H:i' ), 'endTime' => $end->setTimezone( $timezone )->format( 'H:i' ), 'timeLabel' => DateTimeHelper::format_utc_datetime( (string) $row['start_utc'], $timezone->getName(), DateTimeHelper::time_display_format() ), 'endTimeLabel' => DateTimeHelper::format_utc_datetime( (string) $row['end_utc'], $timezone->getName(), DateTimeHelper::time_display_format() ), 'localLabel' => DateTimeHelper::format_utc_datetime( (string) $row['start_utc'], $timezone->getName() ), 'customer' => (string) $row['customer_name'], 'customerEmail' => (string) $row['customer_email'], 'customerPhone' => (string) $row['customer_phone'], 'service' => (string) $row['service_name'], 'serviceColor' => \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) $row['service_color'] ) ) ?: '#6b7280', 'status' => (string) $row['status_label'], 'statusSlug' => (string) $row['status_slug'], 'statusColor' => \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) $row['status_color'] ) ) ?: '#172033', 'blocksAvailability' => ! empty( $row['blocks_availability'] ), 'paymentStatus' => (string) $row['payment_status'], 'paymentPlan' => ! empty( $row['has_payment_plan'] ), 'priceCents' => (int) $row['price_cents'], 'currency' => (string) $row['currency'], 'actions' => array( 'edit' => admin_url( 'admin.php?page=abientot-bookings&booking_id=' . absint( $row['id'] ) ), ), ); } private function render_booking_day_list( string $date, int $current_booking_id ): void { if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) { echo '

' . esc_html__( 'Select a date to see that day.', 'abientot' ) . '

'; return; } $timezone = DateTimeHelper::business_timezone(); $rows = $this->appointments->list_for_admin( array( 'date_from_utc' => DateTimeHelper::local_to_utc_sql( $date . ' 00:00:00', $timezone ), 'date_to_utc' => DateTimeHelper::local_to_utc_sql( $date . ' 23:59:59', $timezone ), 'order' => 'ASC', ), 50, 0 ); if ( ! $rows ) { echo '

' . esc_html__( 'No other bookings for this day.', 'abientot' ) . '

'; return; } echo '
'; foreach ( $rows as $row ) { $color = \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) ( $row['status_color'] ?: $row['service_color'] ) ) ) ?: '#172033'; $current = (int) $row['id'] === $current_booking_id ? ' is-current-booking' : ''; printf( '%4$s - %5$s%6$s%7$s ยท %8$s', esc_attr( $current ), esc_url( admin_url( 'admin.php?page=abientot-bookings&booking_id=' . absint( $row['id'] ) ) ), esc_attr( $color ), esc_html( DateTimeHelper::format_utc_datetime( (string) $row['start_utc'], (string) $row['timezone'], DateTimeHelper::time_display_format() ) ), esc_html( DateTimeHelper::format_utc_datetime( (string) $row['end_utc'], (string) $row['timezone'], DateTimeHelper::time_display_format() ) ), esc_html( (string) $row['service_name'] ), esc_html( (string) $row['customer_name'] ), esc_html( (string) $row['status_label'] ) ); } echo '
'; } private function render_pagination( int $total, int $limit, int $page_number, array $filters ): void { $total_pages = (int) ceil( $total / $limit ); if ( $total_pages <= 1 ) { return; } echo '

'; for ( $i = 1; $i <= $total_pages; $i++ ) { $args = array_merge( array( 'page' => 'abientot-bookings', 'paged' => $i ), $this->query_args_from_filters( $filters ) ); $pagination_url = add_query_arg( $args, admin_url( 'admin.php' ) ); printf( '%3$s ', esc_attr( $i === $page_number ? 'button-primary' : '' ), esc_url( $pagination_url ), esc_html( (string) $i ) ); } echo '

'; } private function acquire_booking_lock(): void { if ( BookingLock::is_held() ) { return; } if ( BookingLock::acquire() ) { $this->owns_booking_lock = true; return; } $message = __( 'Another booking is being saved. Please try again.', 'abientot' ); if ( wp_doing_ajax() ) { wp_send_json_error( array( 'message' => $message ), 409 ); } wp_die( esc_html( $message ), '', array( 'response' => 409, 'back_link' => true ) ); } private function release_booking_lock(): void { if ( $this->owns_booking_lock ) { $this->owns_booking_lock = false; BookingLock::release(); } } /** Deliver only once the caller has also released its mutex. */ private function notify_after_unlock( callable $send ): void { \Abientot\Support\BookingEvents::notify_after_unlock( $send ); } private function validate_status_transition( array $booking, array $status ): void { // Retained resource reactivation is checked by its transaction-owned guard. if ( ( new \Abientot\Repositories\ResourceBookingRepository() )->snapshot( (int) $booking['id'] ) ) { return; } $previous = $this->statuses->find( (int) $booking['status_id'] ); if ( empty( $status['blocks_availability'] ) || ! empty( $previous['blocks_availability'] ) ) { return; } $service = $this->services->find( (int) $booking['service_id'] ); $staff = $this->staff->find( (int) $booking['staff_id'] ); $location = $this->locations->find( (int) $booking['location_id'] ); $slot = array( 'ok' => false, 'notice' => 'invalid-booking' ); if ( $service && $staff && $location ) { $timezone = new DateTimeZone( (string) $booking['timezone'] ); $slot = $this->validated_slot( $service, $staff, $location, $status, $this->local_input_value( (string) $booking['start_utc'], $timezone->getName() ), $booking, $timezone ); } if ( ! $slot['ok'] ) { $message = $this->notice_message( (string) $slot['notice'] ); if ( wp_doing_ajax() ) { wp_send_json_error( array( 'message' => $message ), 409 ); } wp_die( esc_html( $message ), '', array( 'response' => 409, 'back_link' => true ) ); } } private function validated_slot( array $service, array $staff, array $location, array $status, string $start_local, ?array $existing, DateTimeZone $timezone ): array { if ( $existing && ( new \Abientot\Repositories\ResourceBookingRepository() )->snapshot( (int) $existing['id'] ) ) { $original = $this->local_input_value( (string) $existing['start_utc'], (string) $existing['timezone'] ); if ( (int) $existing['service_id'] !== (int) $service['id'] || (int) $existing['staff_id'] !== (int) $staff['id'] || (int) $existing['location_id'] !== (int) $location['id'] || $start_local !== $original ) { return array( 'ok' => false, 'notice' => 'invalid-booking' ); } return array( 'ok' => true, 'start_utc' => $existing['start_utc'], 'end_utc' => $existing['end_utc'] ); } if ( empty( $staff['id'] ) || empty( $location['id'] ) ) { return array( 'ok' => false, 'notice' => 'invalid-booking' ); } $local = DateTimeImmutable::createFromFormat( '!Y-m-d\TH:i', $start_local, $timezone ); if ( ! $local || $local->format( 'Y-m-d\TH:i' ) !== $start_local ) { return array( 'ok' => false, 'notice' => 'invalid-date' ); } $unchanged_start = $existing && (int) $existing['service_id'] === (int) $service['id'] && (int) $existing['staff_id'] === (int) $staff['id'] && (int) $existing['location_id'] === (int) $location['id'] && $start_local === $this->local_input_value( (string) $existing['start_utc'], $timezone->getName() ); // A repeated DST hour has two UTC instants for the same wall time. $start_utc = $unchanged_start ? (string) $existing['start_utc'] : DateTimeHelper::local_to_utc_sql( str_replace( 'T', ' ', $start_local ) . ':00', $timezone ); $default_staff = $this->staff->default_staff(); $default_location = $this->locations->default_location(); $retained = $existing && ( (int) $existing['staff_id'] !== (int) ( $default_staff['id'] ?? 0 ) || (int) $existing['location_id'] !== (int) ( $default_location['id'] ?? 0 ) ); $structural_change = $existing && ( (string) $existing['start_utc'] !== $start_utc || (int) $existing['service_id'] !== (int) $service['id'] || (int) $existing['staff_id'] !== (int) $staff['id'] || (int) $existing['location_id'] !== (int) $location['id'] ); if ( $existing && ( new \Abientot\Repositories\ResourceBookingRepository() )->snapshot( (int) $existing['id'] ) ) { if ( $structural_change ) { return array( 'ok' => false, 'notice' => 'retained-team-booking' ); } // The checked transaction guard verifies retained allocation on reactivation. return array( 'ok' => true, 'start_utc' => (string) $existing['start_utc'], 'end_utc' => (string) $existing['end_utc'] ); } if ( $retained && $structural_change && ! apply_filters( 'abientot_can_edit_booking_structure', false, $existing ) ) { return array( 'ok' => false, 'notice' => 'retained-team-booking' ); } if ( $existing && ( new \Abientot\Repositories\GroupBookingRepository() )->membership( (int) $existing['id'] ) ) { if ( $structural_change ) { return array( 'ok' => false, 'notice' => 'group-booking' ); } // The session owns the staff interval. Transactional membership guard checks // its state, final seat and duplicate customer when this member reactivates. return array( 'ok' => true, 'start_utc' => (string) $existing['start_utc'], 'end_utc' => (string) $existing['end_utc'] ); } $duration = $existing && (int) $existing['service_id'] === (int) $service['id'] ? max( 1, (int) round( ( strtotime( $existing['end_utc'] . ' UTC' ) - strtotime( $existing['start_utc'] . ' UTC' ) ) / 60 ) ) : max( 1, (int) $service['duration_minutes'] ); $duration = max( 1, (int) apply_filters( 'abientot_admin_booking_duration_minutes',$duration,( new \Abientot\Repositories\ResourceBookingRepository() )->internal_service( (int) $service['id'] ) ) ); $previous_status = $existing ? $this->statuses->find( (int) $existing['status_id'] ) : null; if ( $existing && (int) $existing['location_id'] === (int) $location['id'] && (int) $existing['staff_id'] === (int) $staff['id'] && (string) $existing['start_utc'] === $start_utc && (int) $existing['service_id'] === (int) $service['id'] && ( empty( $status['blocks_availability'] ) || ! empty( $previous_status['blocks_availability'] ) ) ) { // Preserve the historical interval when only contact/status/notes change. return array( 'ok' => true, 'start_utc' => $start_utc, 'end_utc' => (string) $existing['end_utc'] ); } if ( empty( $status['blocks_availability'] ) ) { if ( ! $this->availability->valid_booking_context( array( 'service_id' => (int) $service['id'], 'staff_id' => (int) $staff['id'], 'location_id' => (int) $location['id'], 'start_local' => $start_local, 'exclude_appointment_id' => $existing ? (int) $existing['id'] : null ) ) ) { return array( 'ok' => false, 'notice' => 'invalid-booking' ); } $end_utc = ( new DateTimeImmutable( $start_utc, DateTimeHelper::utc_timezone() ) )->modify( '+' . $duration . ' minutes' )->format( 'Y-m-d H:i:s' ); return array( 'ok' => true, 'start_utc' => $start_utc, 'end_utc' => $end_utc ); } // Availability interprets local inputs in the current resource timezone. Preserve // the requested instant when checking a historical timezone (e.g. reactivation). $availability_local = $this->local_input_value( $start_utc, $this->timezone_for_booking( $staff, $location )->getName() ); $validation_args = array( 'service_id' => (int) $service['id'], 'duration_minutes' => $duration, 'staff_id' => (int) $staff['id'], 'location_id' => (int) $location['id'], 'start_local' => $availability_local, 'exclude_appointment_id' => $existing ? (int) $existing['id'] : null, 'ignore_minimum_notice' => true, ); $result = $unchanged_start ? $this->availability->validate_instant( $validation_args, new DateTimeImmutable( $start_utc, DateTimeHelper::utc_timezone() ) ) : $this->availability->validate_slot( $validation_args ); if ( empty( $result['available'] ) ) { return array( 'ok' => false, 'notice' => 'slot-' . sanitize_key( (string) ( $result['reason'] ?? 'unavailable' ) ) ); } return array( 'ok' => true, 'start_utc' => (string) $result['start']->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ), 'end_utc' => (string) $result['end']->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ) ); } private function slot_for_existing_booking( array $booking, string $start_local ): array { if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/', $start_local ) ) { return array( 'ok' => false, 'notice' => 'invalid-date' ); } $service = $this->services->find( (int) $booking['service_id'] ); $status = $this->statuses->find( (int) $booking['status_id'] ); $staff = \Abientot\Support\BookingStaff::resolve( 'fixed', $booking, (int) $booking['service_id'] ); $location = $this->locations->find( (int) $booking['location_id'] ) ?: array( 'id' => 0 ); if ( ! $service || ! $status ) { return array( 'ok' => false, 'notice' => 'invalid-booking' ); } return $this->validated_slot( $service, $staff, $location, $status, $start_local, $booking, $this->timezone_for_booking( $staff, $location ) ); } private function filters_from_request(): array { $filters = array( 'search' => (string) ( Request::view( 's' ) ?? '' ), 'status_id' => (int) Request::view( 'status_id', 'int' ), 'service_id' => (int) Request::view( 'service_id', 'int' ), 'staff_id' => \Abientot\Support\StaffFilter::enabled() ? \Abientot\Support\StaffFilter::normalize( Request::view( 'staff_id' ) ?? 0 ) : 0, 'payment_status' => (string) ( Request::view( 'payment_status', 'key' ) ?? '' ), 'date_from' => (string) ( Request::view( 'date_from' ) ?? '' ), 'date_to' => (string) ( Request::view( 'date_to' ) ?? '' ), ); if ( ! in_array( $filters['payment_status'], array( '', 'not_required', 'unpaid', 'paid' ), true ) ) { $filters['payment_status'] = ''; } $timezone = DateTimeHelper::business_timezone(); if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $filters['date_from'] ) ) { $filters['date_from_utc'] = DateTimeHelper::local_to_utc_sql( $filters['date_from'] . ' 00:00:00', $timezone ); } if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $filters['date_to'] ) ) { $filters['date_to_utc'] = DateTimeHelper::local_to_utc_sql( $filters['date_to'] . ' 23:59:59', $timezone ); } return $filters; } /** * Reads non-mutating calendar prefill query args for the booking form. * * @return array{service_id:int,start_local:string,first_name:string,last_name:string,email:string,phone:string} */ private function prefill_from_request(): array { $service_id = (int) Request::view( 'service_id', 'int' ); $start_local = (string) ( Request::view( 'start_local' ) ?? '' ); $customer_id = (int) Request::view( 'customer_id', 'int' ); $customer = $customer_id > 0 && current_user_can( 'abientot_manage_customers' ) ? $this->customers->find( $customer_id ) : null; if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/', $start_local ) ) { $start_local = ''; } return array( 'service_id' => $service_id, 'start_local' => $start_local, 'first_name' => $customer ? (string) $customer['first_name'] : '', 'last_name' => $customer ? (string) $customer['last_name'] : '', 'email' => $customer ? (string) $customer['email'] : '', 'phone' => $customer ? (string) $customer['phone'] : '', ); } private function active_booking_date( ?array $booking, bool $prefill ): string { if ( $booking ) { return $this->local_datetime_label( (string) $booking['start_utc'], (string) $booking['timezone'], 'Y-m-d' ); } if ( $prefill ) { $request = $this->prefill_from_request(); if ( '' !== $request['start_local'] ) { return substr( $request['start_local'], 0, 10 ); } } return wp_date( 'Y-m-d' ); } private function query_args_from_filters( array $filters ): array { $args = array(); foreach ( array( 'search' => 's', 'status_id' => 'status_id', 'service_id' => 'service_id', 'staff_id' => 'staff_id', 'payment_status' => 'payment_status', 'date_from' => 'date_from', 'date_to' => 'date_to' ) as $key => $arg ) { if ( ! empty( $filters[ $key ] ) ) { $args[ $arg ] = $filters[ $key ]; } } return $args; } private function merge_customer_snapshot( ?array $existing, array $fields ): array { $old = json_decode( (string) ( $existing['customer_fields_json'] ?? '' ), true ); $keys = array_column( $fields, 'key' ); foreach ( is_array( $old ) ? $old : array() as $field ) { if ( is_array( $field ) && ! in_array( $field['key'] ?? '', $keys, true ) ) { $fields[] = $field; } } return $fields; } private function default_staff(): array { $staff = $this->staff->default_staff(); return is_array( $staff ) ? $staff : array( 'id' => 0, 'display_name' => __( 'Default Staff', 'abientot' ), 'timezone' => wp_timezone_string() ); } private function default_location(): array { $location = $this->locations->default_location(); return is_array( $location ) ? $location : array( 'id' => 0, 'name' => __( 'Default Location', 'abientot' ), 'timezone' => wp_timezone_string() ); } private function timezone_for_booking( array $staff, array $location ): DateTimeZone { return \Abientot\Support\BookingStaff::timezone( $staff, $location ); } private function default_status_id( array $statuses ): int { $settings = Settings::values(); foreach ( $statuses as $status ) { if ( (string) $status['slug'] === (string) $settings['default_booking_status'] ) { return (int) $status['id']; } } return (int) ( $statuses[0]['id'] ?? 0 ); } private function notice(): void { $notice = RedirectNotice::get( 'abientot_notice' ); if ( '' === $notice ) { return; } $message = $this->notice_message( $notice ); if ( '' !== $message ) { echo '

' . esc_html( $message ) . '

'; } } private function notice_message( string $notice ): string { $map = array( 'saved' => __( 'Booking saved.', 'abientot' ), 'group-booking' => __( 'Group enrollment times belong to their session. Cancel this enrollment or manage the session instead.', 'abientot' ), 'retained-team-booking' => __( 'This booking retains its team assignment. Use a compatible Pro edition to change its service, staff or time.', 'abientot' ), 'invalid-price' => __( 'Enter an exact price supported by the selected currency. Use whole amounts for zero-decimal currencies; other prices support increments of 0.01.','abientot' ), 'invalid-booking' => __( 'The booking could not be saved.', 'abientot' ), 'invalid-date' => __( 'Enter a valid booking date and time.', 'abientot' ), 'invalid-phone' => __( 'Enter a valid phone number. Select its country or include the +country code.', 'abientot' ), 'missing-customer' => __( 'Enter at least one customer identifier.', 'abientot' ), 'slot-minimum_notice' => __( 'The selected time is inside the minimum notice window.', 'abientot' ), 'slot-booking_conflict' => __( 'The selected time conflicts with another booking.', 'abientot' ), 'slot-dst_invalid' => __( 'The selected local time does not exist in the booking timezone.', 'abientot' ), ); return (string) ( $map[ $notice ] ?? __( 'The selected time is no longer available. Please choose another time.', 'abientot' ) ); } private function redirect( string $notice, int $booking_id = 0 ): void { $return_url = null !== $this->input ? $this->input->url( 'abientot_return_url' ) : ''; if ( '' !== $return_url ) { $validated = wp_validate_redirect( $return_url, '' ); if ( $validated && $this->is_abientot_admin_url( $validated ) ) { wp_safe_redirect( RedirectNotice::url( $validated, array( 'abientot_notice' => $notice ) ) ); exit; } } $url = admin_url( 'admin.php?page=abientot-bookings' ); if ( $booking_id > 0 ) { $url = add_query_arg( 'booking_id', $booking_id, $url ); } wp_safe_redirect( RedirectNotice::url( $url, array( 'abientot_notice' => $notice ) ) ); exit; } private function is_abientot_admin_url( string $url ): bool { $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['query'] ) ) { return false; } parse_str( (string) $parts['query'], $query ); $page = isset( $query['page'] ) ? sanitize_key( (string) $query['page'] ) : ''; return 'abientot' === $page || 0 === strpos( $page, 'abientot-' ); } /** Capability check for handlers that verified their nonce first. */ private function authorize( string $capability ): void { if ( ! current_user_can( $capability ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abientot' ), '', array( 'response' => 403 ) ); } } /** Fields of the current booking form; only handlers set them, after verifying the nonce. */ private function input(): Input { if ( null === $this->input ) { wp_die( esc_html__( 'The booking could not be saved.', 'abientot' ), '', array( 'response' => 400 ) ); } return $this->input; } /** * One booking field, sanitized for $type: an extension override when one is active, * otherwise the verified request field. Null when the field is absent. * * @return mixed */ private function field( string $key, string $type ) { if ( array_key_exists( $key, $this->overrides ) ) { $value = $this->overrides[ $key ]; if ( null === $value ) { return null; } if ( 'int' === $type ) { return is_scalar( $value ) ? absint( $value ) : 0; } if ( ! is_scalar( $value ) ) { return ''; } $value = (string) $value; switch ( $type ) { case 'textarea': return sanitize_textarea_field( $value ); case 'html': return wp_kses_post( $value ); case 'key': return sanitize_key( $value ); case 'email': return sanitize_email( $value ); default: return sanitize_text_field( $value ); } } $input = $this->input(); if ( ! $input->has( $key ) ) { return null; } switch ( $type ) { case 'int': return $input->int( $key ); case 'textarea': return $input->textarea( $key ); case 'html': return $input->html( $key ); case 'key': return $input->key( $key ); case 'email': return $input->email( $key ); default: return $input->text( $key ); } } /** * The booking request fields passed to extensions, with active overrides applied. * * @return array */ private function request_fields(): array { $values = $this->input()->fields( self::REQUEST_FIELDS ); foreach ( self::REQUEST_FIELDS as $key => $type ) { if ( is_int( $key ) ) { $key = $type; $type = 'text'; } if ( ! array_key_exists( $key, $this->overrides ) ) { continue; } $value = $this->overrides[ $key ]; if ( null === $value ) { unset( $values[ $key ] ); } elseif ( 'email' === $type ) { $values[ $key ] = is_string( $value ) ? sanitize_email( $value ) : ''; } else { $values[ $key ] = map_deep( $value, 'textarea' === $type ? 'sanitize_textarea_field' : 'sanitize_text_field' ); } } return $values; } private function input_row( string $name, string $label, string $value, string $type = 'text', bool $required = false, string $min = '', string $max = '', string $step = '', bool $readonly = false ): void { if ( 'tel' === $type ) { echo ''; \Abientot\Support\PhoneField::render( $name, $value, 'phone_country', array( 'id' => $name, 'required' => $required, 'readonly' => $readonly ), $label ); echo ''; return; } ?> > '; } private function select_row( string $name, string $label, string $value, array $choices, bool $readonly = false ): void { if ( $readonly ) { echo '' . esc_html( $label ) . '' . esc_html( $choices[ $value ] ?? $value ) . '

' . esc_html__( 'This booking has an immutable payment plan. Confirm receipts and refunds through Online payments; ordinary edits preserve its financial records.', 'abientot' ) . '

'; return; } echo ''; } private function text_post( string $key ): string { return (string) ( $this->field( $key, 'text' ) ?? '' ); } private function textarea_post( string $key ): string { return (string) ( $this->field( $key, 'textarea' ) ?? '' ); } private function email_body_post( string $key ): string { return (string) ( $this->field( $key, 'html' ) ?? '' ); } private function int_post( string $key ): int { return (int) ( $this->field( $key, 'int' ) ?? 0 ); } private function datetime_post( string $key ): string { $value = (string) ( $this->field( $key, 'text' ) ?? '' ); return preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/', $value ) ? $value : ''; } private function price_cents_post( string $key ): int { $value = (string) ( $this->field( $key, 'text' ) ?? '0' ); try { $cents = \Abientot\Support\MoneyInput::parse( $value,'USD' ); $id = $this->int_post( 'booking_id' ); $existing = $id ? $this->appointments->find( $id ) : null; $currency = $this->currency_post( 'currency' ); if ( ! $existing || (int) $existing['price_cents'] !== $cents || $existing['currency'] !== $currency ) { \Abientot\Support\Currency::validate( $cents,$currency ); } return $cents; } catch ( \InvalidArgumentException $error ) { throw new \InvalidArgumentException( 'abientot_invalid_price' ); } } private function currency_post( string $key ): string { $currency = strtoupper( $this->text_post( $key ) ); return preg_match( '/^[A-Z]{3}$/', $currency ) ? $currency : 'USD'; } private function choice_post( string $key, array $choices, string $default ): string { $value = (string) ( $this->field( $key, 'key' ) ?? $default ); return in_array( $value, $choices, true ) ? $value : $default; } private function local_input_value( string $utc_datetime, string $timezone ): string { return $this->local_datetime_label( $utc_datetime, $timezone, 'Y-m-d\TH:i' ); } private function local_datetime_label( string $utc_datetime, string $timezone, string $format = '' ): string { if ( '' === $format ) { return DateTimeHelper::format_utc_datetime( $utc_datetime, $timezone ); } try { $date = new DateTimeImmutable( $utc_datetime, DateTimeHelper::utc_timezone() ); return $date->setTimezone( new DateTimeZone( $timezone ) )->format( $format ); } catch ( Exception $exception ) { return get_date_from_gmt( $utc_datetime, $format ); } } private function local_date_label( string $local_date ): string { if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $local_date ) ) { return ''; } return DateTimeHelper::format_local_datetime( $local_date . ' 00:00:00', null, DateTimeHelper::date_display_format() ); } private function format_price( int $cents ): string { return number_format( $cents / 100, 2, '.', '' ); } private function format_money( int $cents, string $currency ): string { return \Abientot\Support\Currency::format( $cents,$currency ); } private function label_from_slug( string $slug ): string { return ucwords( str_replace( '_', ' ', $slug ) ); } private function csv_cell( string $value ): string { $value = str_replace( array( "\r", "\n" ), ' ', $value ); return preg_match( '/^[\x00-\x20]*[=+\-@]/', $value ) ? "'" . $value : $value; } }