_x( 'Bookings', 'post type general name', 'wp-bnb' ), 'singular_name' => _x( 'Booking', 'post type singular name', 'wp-bnb' ), 'menu_name' => _x( 'Bookings', 'admin menu', 'wp-bnb' ), 'name_admin_bar' => _x( 'Booking', 'add new on admin bar', 'wp-bnb' ), 'add_new' => _x( 'Add New', 'booking', 'wp-bnb' ), 'add_new_item' => __( 'Add New Booking', 'wp-bnb' ), 'new_item' => __( 'New Booking', 'wp-bnb' ), 'edit_item' => __( 'Edit Booking', 'wp-bnb' ), 'view_item' => __( 'View Booking', 'wp-bnb' ), 'all_items' => __( 'Bookings', 'wp-bnb' ), 'search_items' => __( 'Search Bookings', 'wp-bnb' ), 'parent_item_colon' => __( 'Parent Bookings:', 'wp-bnb' ), 'not_found' => __( 'No bookings found.', 'wp-bnb' ), 'not_found_in_trash' => __( 'No bookings found in Trash.', 'wp-bnb' ), 'archives' => __( 'Booking archives', 'wp-bnb' ), 'insert_into_item' => __( 'Insert into booking', 'wp-bnb' ), 'uploaded_to_this_item' => __( 'Uploaded to this booking', 'wp-bnb' ), 'filter_items_list' => __( 'Filter bookings list', 'wp-bnb' ), 'items_list_navigation' => __( 'Bookings list navigation', 'wp-bnb' ), 'items_list' => __( 'Bookings list', 'wp-bnb' ), ); $args = array( 'labels' => $labels, 'public' => false, 'publicly_queryable' => false, 'show_ui' => true, 'show_in_menu' => 'wp-bnb', 'query_var' => false, 'capability_type' => 'post', 'has_archive' => false, 'hierarchical' => false, 'menu_position' => null, 'menu_icon' => 'dashicons-calendar-alt', 'supports' => array( 'revisions' ), 'show_in_rest' => true, 'rest_base' => 'bookings', 'rest_controller_class' => 'WP_REST_Posts_Controller', ); register_post_type( self::POST_TYPE, $args ); } /** * Add meta boxes. * * @return void */ public static function add_meta_boxes(): void { add_meta_box( 'bnb_booking_room_dates', __( 'Room & Dates', 'wp-bnb' ), array( self::class, 'render_room_dates_meta_box' ), self::POST_TYPE, 'normal', 'high' ); add_meta_box( 'bnb_booking_guest', __( 'Guest Information', 'wp-bnb' ), array( self::class, 'render_guest_meta_box' ), self::POST_TYPE, 'normal', 'high' ); add_meta_box( 'bnb_booking_services', __( 'Additional Services', 'wp-bnb' ), array( self::class, 'render_services_meta_box' ), self::POST_TYPE, 'normal', 'default' ); add_meta_box( 'bnb_booking_pricing', __( 'Pricing', 'wp-bnb' ), array( self::class, 'render_pricing_meta_box' ), self::POST_TYPE, 'normal', 'default' ); add_meta_box( 'bnb_booking_status', __( 'Status & Notes', 'wp-bnb' ), array( self::class, 'render_status_meta_box' ), self::POST_TYPE, 'side', 'high' ); } /** * Render room and dates meta box. * * @param \WP_Post $post Current post object. * @return void */ public static function render_room_dates_meta_box( \WP_Post $post ): void { wp_nonce_field( 'bnb_booking_meta', 'bnb_booking_meta_nonce' ); $room_id = get_post_meta( $post->ID, self::META_PREFIX . 'room_id', true ); $check_in = get_post_meta( $post->ID, self::META_PREFIX . 'check_in', true ); $check_out = get_post_meta( $post->ID, self::META_PREFIX . 'check_out', true ); $rooms = get_posts( array( 'post_type' => Room::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', ) ); // Group rooms by building. $rooms_by_building = array(); foreach ( $rooms as $room ) { $building = Room::get_building( $room->ID ); $building_name = $building ? $building->post_title : __( 'No Building', 'wp-bnb' ); $building_id = $building ? $building->ID : 0; if ( ! isset( $rooms_by_building[ $building_id ] ) ) { $rooms_by_building[ $building_id ] = array( 'name' => $building_name, 'rooms' => array(), ); } $rooms_by_building[ $building_id ]['rooms'][] = $room; } ?>

' . esc_html__( 'Add a room', 'wp-bnb' ) . '' ); ?>

ID, self::META_PREFIX . 'guest_id', true ); $guest_name = get_post_meta( $post->ID, self::META_PREFIX . 'guest_name', true ); $guest_email = get_post_meta( $post->ID, self::META_PREFIX . 'guest_email', true ); $guest_phone = get_post_meta( $post->ID, self::META_PREFIX . 'guest_phone', true ); $adults = get_post_meta( $post->ID, self::META_PREFIX . 'adults', true ); $children = get_post_meta( $post->ID, self::META_PREFIX . 'children', true ); $guest_notes = get_post_meta( $post->ID, self::META_PREFIX . 'guest_notes', true ); // If guest_id exists, get guest data from Guest CPT. $linked_guest = null; if ( $guest_id ) { $linked_guest = get_post( $guest_id ); if ( $linked_guest && Guest::POST_TYPE === $linked_guest->post_type ) { $guest_name = Guest::get_full_name( $guest_id ); $guest_email = get_post_meta( $guest_id, '_bnb_guest_email', true ); $guest_phone = get_post_meta( $guest_id, '_bnb_guest_phone', true ); } else { $linked_guest = null; $guest_id = 0; } } ?>

post_title ); ?>

>
>
  

ID, self::SERVICES_META_KEY, true ) ?: array(); $check_in = get_post_meta( $post->ID, self::META_PREFIX . 'check_in', true ); $check_out = get_post_meta( $post->ID, self::META_PREFIX . 'check_out', true ); $nights = ( $check_in && $check_out ) ? self::calculate_nights( $check_in, $check_out ) : 1; // Get all active services. $available_services = Service::get_services_for_booking(); if ( empty( $available_services ) ) { ?>

' . esc_html__( 'Included', 'wp-bnb' ) . ''; } else { echo esc_html( $service['formatted_price'] ); } ?> 1 && 'included' !== $service['pricing_type'] ) : ?> > > =
ID ); echo esc_html( Calculator::formatPrice( $services_total ) ); ?>
ID, self::META_PREFIX . 'calculated_price', true ); $price_breakdown = get_post_meta( $post->ID, self::META_PREFIX . 'price_breakdown', true ); $override_price = get_post_meta( $post->ID, self::META_PREFIX . 'override_price', true ); $currency = get_option( 'wp_bnb_currency', 'CHF' ); ?> ID ); if ( $services_total > 0 ) : ?>
' . esc_html( Calculator::formatPrice( (float) $calculated_price ) ) . ''; } else { esc_html_e( 'Price will be calculated when room and dates are selected.', 'wp-bnb' ); } ?>

ID, self::META_PREFIX . 'status', true ) ?: 'pending'; $notes = get_post_meta( $post->ID, self::META_PREFIX . 'notes', true ); $confirmed_at = get_post_meta( $post->ID, self::META_PREFIX . 'confirmed_at', true ); $statuses = self::get_booking_statuses(); $colors = self::get_status_colors(); ?>


post_type ) { update_post_meta( $post_id, self::META_PREFIX . 'guest_id', $guest_id ); // Sync guest data from Guest CPT for searching/display purposes. update_post_meta( $post_id, self::META_PREFIX . 'guest_name', Guest::get_full_name( $guest_id ) ); update_post_meta( $post_id, self::META_PREFIX . 'guest_email', get_post_meta( $guest_id, '_bnb_guest_email', true ) ); update_post_meta( $post_id, self::META_PREFIX . 'guest_phone', get_post_meta( $guest_id, '_bnb_guest_phone', true ) ); } else { delete_post_meta( $post_id, self::META_PREFIX . 'guest_id' ); } } else { // No guest_id selected - get guest data from form fields. $guest_name = isset( $_POST['bnb_booking_guest_name'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_booking_guest_name'] ) ) : ''; $guest_email = isset( $_POST['bnb_booking_guest_email'] ) ? sanitize_email( wp_unslash( $_POST['bnb_booking_guest_email'] ) ) : ''; $guest_phone = isset( $_POST['bnb_booking_guest_phone'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_booking_guest_phone'] ) ) : ''; // Try to find or create a Guest post. $linked_guest_id = self::find_or_create_guest( $guest_name, $guest_email, $guest_phone ); if ( $linked_guest_id ) { // Link to the guest and sync data. update_post_meta( $post_id, self::META_PREFIX . 'guest_id', $linked_guest_id ); update_post_meta( $post_id, self::META_PREFIX . 'guest_name', Guest::get_full_name( $linked_guest_id ) ); update_post_meta( $post_id, self::META_PREFIX . 'guest_email', get_post_meta( $linked_guest_id, '_bnb_guest_email', true ) ); update_post_meta( $post_id, self::META_PREFIX . 'guest_phone', get_post_meta( $linked_guest_id, '_bnb_guest_phone', true ) ); } else { // Fallback: save guest data directly to booking meta if guest creation failed. delete_post_meta( $post_id, self::META_PREFIX . 'guest_id' ); update_post_meta( $post_id, self::META_PREFIX . 'guest_name', $guest_name ); update_post_meta( $post_id, self::META_PREFIX . 'guest_email', $guest_email ); update_post_meta( $post_id, self::META_PREFIX . 'guest_phone', $guest_phone ); } } // Guest notes are always saved (per-booking notes). if ( isset( $_POST['bnb_booking_guest_notes'] ) ) { update_post_meta( $post_id, self::META_PREFIX . 'guest_notes', sanitize_textarea_field( wp_unslash( $_POST['bnb_booking_guest_notes'] ) ) ); } // Guest counts. if ( isset( $_POST['bnb_booking_adults'] ) ) { update_post_meta( $post_id, self::META_PREFIX . 'adults', absint( $_POST['bnb_booking_adults'] ) ); } if ( isset( $_POST['bnb_booking_children'] ) ) { update_post_meta( $post_id, self::META_PREFIX . 'children', absint( $_POST['bnb_booking_children'] ) ); } // Notes. if ( isset( $_POST['bnb_booking_notes'] ) ) { update_post_meta( $post_id, self::META_PREFIX . 'notes', sanitize_textarea_field( wp_unslash( $_POST['bnb_booking_notes'] ) ) ); } // Pricing. $override_price = isset( $_POST['bnb_booking_override_price'] ) && '' !== $_POST['bnb_booking_override_price'] ? floatval( $_POST['bnb_booking_override_price'] ) : null; if ( null !== $override_price ) { update_post_meta( $post_id, self::META_PREFIX . 'override_price', $override_price ); update_post_meta( $post_id, self::META_PREFIX . 'calculated_price', $override_price ); } else { delete_post_meta( $post_id, self::META_PREFIX . 'override_price' ); // Calculate price if room and dates are set. if ( $room_id && $check_in && $check_out ) { try { $calculator = new Calculator( $room_id, $check_in, $check_out ); $price = $calculator->calculate(); $breakdown = $calculator->getBreakdown(); update_post_meta( $post_id, self::META_PREFIX . 'calculated_price', $price ); update_post_meta( $post_id, self::META_PREFIX . 'price_breakdown', $breakdown ); } catch ( \Exception $e ) { // Keep existing price if calculation fails. } } } // Services. $services_data = array(); if ( isset( $_POST['bnb_booking_services'] ) && is_array( $_POST['bnb_booking_services'] ) ) { $nights = ( $check_in && $check_out ) ? self::calculate_nights( $check_in, $check_out ) : 1; foreach ( $_POST['bnb_booking_services'] as $service_id => $service_input ) { $service_id = absint( $service_id ); if ( ! $service_id ) { continue; } // Only include if selected checkbox is checked. if ( empty( $service_input['selected'] ) ) { continue; } // Verify service exists and is active. $service_data = Service::get_service_data( $service_id ); if ( ! $service_data || 'active' !== $service_data['status'] ) { continue; } $quantity = isset( $service_input['quantity'] ) ? absint( $service_input['quantity'] ) : 1; $quantity = max( 1, min( $quantity, $service_data['max_quantity'] ) ); $price = Service::calculate_service_price( $service_id, $quantity, $nights ); $services_data[] = array( 'service_id' => $service_id, 'quantity' => $quantity, 'price' => $price, 'pricing_type' => $service_data['pricing_type'], ); } } update_post_meta( $post_id, self::SERVICES_META_KEY, $services_data ); // Trigger status change action. if ( $old_status && $status !== $old_status ) { /** * Fires when a booking status changes. * * @param int $post_id Booking post ID. * @param string $old_status Previous status. * @param string $status New status. */ do_action( 'wp_bnb_booking_status_changed', $post_id, $old_status, $status ); } // Generate comprehensive title with guest name, room, and dates. self::generate_comprehensive_title( $post_id, $room_id, $check_in, $check_out ); } /** * Generate a comprehensive title for a booking. * * Format: "Guest Name (DD.MM - DD.MM.YYYY)" * * @param int $post_id Booking post ID. * @param int $room_id Room post ID (unused, kept for signature compatibility). * @param string $check_in Check-in date (Y-m-d). * @param string $check_out Check-out date (Y-m-d). * @return void */ private static function generate_comprehensive_title( int $post_id, int $room_id, string $check_in, string $check_out ): void { // Get guest name. $guest_name = get_post_meta( $post_id, self::META_PREFIX . 'guest_name', true ); if ( empty( $guest_name ) ) { $guest_name = __( 'Unknown Guest', 'wp-bnb' ); } // Format dates. $date_part = ''; if ( $check_in && $check_out ) { $check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in ); $check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out ); if ( $check_in_date && $check_out_date ) { // Same year: "01.02 - 05.02.2026" // Different year: "28.12.2025 - 02.01.2026" if ( $check_in_date->format( 'Y' ) === $check_out_date->format( 'Y' ) ) { $date_part = sprintf( '%s - %s', $check_in_date->format( 'd.m' ), $check_out_date->format( 'd.m.Y' ) ); } else { $date_part = sprintf( '%s - %s', $check_in_date->format( 'd.m.Y' ), $check_out_date->format( 'd.m.Y' ) ); } } } // Build title: "Guest Name (dates)". $title = $guest_name; if ( $date_part ) { $title .= sprintf( ' (%s)', $date_part ); } // Update the post title directly in the database to avoid infinite loop. global $wpdb; $wpdb->update( $wpdb->posts, array( 'post_title' => $title ), array( 'ID' => $post_id ), array( '%s' ), array( '%d' ) ); // Clear post cache. clean_post_cache( $post_id ); } /** * Find an existing guest by email or create a new one. * * @param string $name Guest full name. * @param string $email Guest email. * @param string $phone Guest phone (optional). * @return int|null Guest post ID or null on failure. */ private static function find_or_create_guest( string $name, string $email, string $phone = '' ): ?int { // Need at least a name to create a guest. if ( empty( $name ) ) { return null; } // Try to find existing guest by email. if ( ! empty( $email ) ) { $existing_guest = Guest::get_by_email( $email ); if ( $existing_guest ) { return $existing_guest->ID; } } // Parse name into first/last name. $name_parts = explode( ' ', trim( $name ), 2 ); $first_name = $name_parts[0] ?? ''; $last_name = $name_parts[1] ?? ''; // Create new guest post. $guest_id = wp_insert_post( array( 'post_type' => Guest::POST_TYPE, 'post_status' => 'publish', 'post_title' => $name, ) ); if ( is_wp_error( $guest_id ) || ! $guest_id ) { return null; } // Save guest meta. update_post_meta( $guest_id, '_bnb_guest_first_name', $first_name ); update_post_meta( $guest_id, '_bnb_guest_last_name', $last_name ); if ( ! empty( $email ) ) { update_post_meta( $guest_id, '_bnb_guest_email', $email ); } if ( ! empty( $phone ) ) { update_post_meta( $guest_id, '_bnb_guest_phone', $phone ); } // Set default status. update_post_meta( $guest_id, '_bnb_guest_status', 'active' ); return $guest_id; } /** * Add custom columns to the post list. * * @param array $columns Existing columns. * @return array */ public static function add_columns( array $columns ): array { $new_columns = array(); foreach ( $columns as $key => $value ) { $new_columns[ $key ] = $value; if ( 'title' === $key ) { $new_columns['room'] = __( 'Room', 'wp-bnb' ); $new_columns['guest'] = __( 'Guest', 'wp-bnb' ); $new_columns['dates'] = __( 'Dates', 'wp-bnb' ); $new_columns['nights'] = __( 'Nights', 'wp-bnb' ); $new_columns['price'] = __( 'Price', 'wp-bnb' ); $new_columns['status'] = __( 'Status', 'wp-bnb' ); } } // Remove date column, we have our own dates. unset( $new_columns['date'] ); return $new_columns; } /** * Render custom column content. * * @param string $column Column name. * @param int $post_id Post ID. * @return void */ public static function render_column( string $column, int $post_id ): void { switch ( $column ) { case 'room': $room_id = (int) get_post_meta( $post_id, self::META_PREFIX . 'room_id', true ); if ( $room_id ) { $room = get_post( $room_id ); if ( $room ) { $building = Room::get_building( $room_id ); printf( '%s', esc_url( get_edit_post_link( $room_id ) ), esc_html( $room->post_title ) ); if ( $building ) { echo '
' . esc_html( $building->post_title ) . ''; } } else { echo '' . esc_html__( 'Room deleted', 'wp-bnb' ) . ''; } } else { echo '—'; } break; case 'guest': $guest_id = (int) get_post_meta( $post_id, self::META_PREFIX . 'guest_id', true ); $guest_name = get_post_meta( $post_id, self::META_PREFIX . 'guest_name', true ); $guest_email = get_post_meta( $post_id, self::META_PREFIX . 'guest_email', true ); if ( $guest_name ) { if ( $guest_id ) { // Linked guest - show link to guest profile. printf( '%s', esc_url( get_edit_post_link( $guest_id ) ), esc_html( $guest_name ) ); echo ' '; } else { echo esc_html( $guest_name ); } if ( $guest_email ) { echo '
' . esc_html( $guest_email ) . ''; } } else { echo '—'; } break; case 'dates': $check_in = get_post_meta( $post_id, self::META_PREFIX . 'check_in', true ); $check_out = get_post_meta( $post_id, self::META_PREFIX . 'check_out', true ); if ( $check_in && $check_out ) { $format = get_option( 'date_format' ); echo esc_html( wp_date( $format, strtotime( $check_in ) ) ); echo '
' . esc_html__( 'to', 'wp-bnb' ) . ' ' . esc_html( wp_date( $format, strtotime( $check_out ) ) ) . ''; } else { echo '—'; } break; case 'nights': $check_in = get_post_meta( $post_id, self::META_PREFIX . 'check_in', true ); $check_out = get_post_meta( $post_id, self::META_PREFIX . 'check_out', true ); if ( $check_in && $check_out ) { $nights = self::calculate_nights( $check_in, $check_out ); echo esc_html( $nights ); } else { echo '—'; } break; case 'price': $room_price = (float) get_post_meta( $post_id, self::META_PREFIX . 'calculated_price', true ); $services_total = self::calculate_booking_services_total( $post_id ); $total_price = $room_price + $services_total; if ( $total_price > 0 ) { echo esc_html( Calculator::formatPrice( $total_price ) ); $override = get_post_meta( $post_id, self::META_PREFIX . 'override_price', true ); if ( $override ) { echo ' *'; } if ( $services_total > 0 ) { echo '
' . esc_html__( 'incl. services', 'wp-bnb' ) . ''; } } elseif ( $room_price > 0 ) { echo esc_html( Calculator::formatPrice( $room_price ) ); } else { echo '—'; } break; case 'status': $status = get_post_meta( $post_id, self::META_PREFIX . 'status', true ) ?: 'pending'; $statuses = self::get_booking_statuses(); $colors = self::get_status_colors(); ?> Room::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', ) ); if ( ! empty( $rooms ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter display only. $selected_room = isset( $_GET['room_id'] ) ? absint( $_GET['room_id'] ) : 0; ?> is_main_query() ) { return; } if ( self::POST_TYPE !== $query->get( 'post_type' ) ) { return; } // Exclude auto-drafts from the list - they're not real bookings. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only. $post_status = isset( $_GET['post_status'] ) ? sanitize_key( $_GET['post_status'] ) : ''; if ( empty( $post_status ) || 'all' === $post_status ) { $query->set( 'post_status', array( 'publish', 'pending', 'draft', 'private' ) ); } $meta_query = array(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only. if ( ! empty( $_GET['room_id'] ) ) { $meta_query[] = array( 'key' => self::META_PREFIX . 'room_id', 'value' => absint( $_GET['room_id'] ), ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only. if ( ! empty( $_GET['booking_status'] ) ) { $meta_query[] = array( 'key' => self::META_PREFIX . 'status', 'value' => sanitize_text_field( wp_unslash( $_GET['booking_status'] ) ), ); } if ( ! empty( $meta_query ) ) { $meta_query['relation'] = 'AND'; $query->set( 'meta_query', $meta_query ); } // Handle sorting. $orderby = $query->get( 'orderby' ); if ( 'check_in' === $orderby ) { $query->set( 'meta_key', self::META_PREFIX . 'check_in' ); $query->set( 'orderby', 'meta_value' ); } elseif ( 'guest_name' === $orderby ) { $query->set( 'meta_key', self::META_PREFIX . 'guest_name' ); $query->set( 'orderby', 'meta_value' ); } elseif ( 'status' === $orderby ) { $query->set( 'meta_key', self::META_PREFIX . 'status' ); $query->set( 'orderby', 'meta_value' ); } } /** * Change title placeholder. * * @param string $placeholder Default placeholder. * @param \WP_Post $post Current post. * @return string */ public static function change_title_placeholder( string $placeholder, \WP_Post $post ): string { if ( self::POST_TYPE === $post->post_type ) { return __( 'Title auto-generated from guest name and dates', 'wp-bnb' ); } return $placeholder; } /** * Show conflict notice in admin. * * @return void */ public static function show_conflict_notice(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Notice display only. if ( isset( $_GET['booking_conflict'] ) && '1' === $_GET['booking_conflict'] ) { ?>

*/ public static function get_booking_statuses(): array { return array( 'pending' => __( 'Pending', 'wp-bnb' ), 'confirmed' => __( 'Confirmed', 'wp-bnb' ), 'checked_in' => __( 'Checked In', 'wp-bnb' ), 'checked_out' => __( 'Checked Out', 'wp-bnb' ), 'cancelled' => __( 'Cancelled', 'wp-bnb' ), ); } /** * Get status color codes. * * @return array */ public static function get_status_colors(): array { return array( 'pending' => '#dba617', 'confirmed' => '#00a32a', 'checked_in' => '#72aee6', 'checked_out' => '#646970', 'cancelled' => '#d63638', ); } /** * Get valid status transitions. * * @return array> */ public static function get_status_transitions(): array { return array( 'pending' => array( 'confirmed', 'cancelled' ), 'confirmed' => array( 'checked_in', 'cancelled' ), 'checked_in' => array( 'checked_out' ), 'checked_out' => array(), 'cancelled' => array( 'pending' ), ); } /** * Check if a status transition is valid. * * @param string $from Current status. * @param string $to New status. * @return bool */ public static function can_transition_to( string $from, string $to ): bool { if ( $from === $to ) { return true; } $transitions = self::get_status_transitions(); return isset( $transitions[ $from ] ) && in_array( $to, $transitions[ $from ], true ); } /** * Generate a booking reference number. * * @return string */ public static function generate_reference(): string { $year = gmdate( 'Y' ); $count = wp_count_posts( self::POST_TYPE ); $total = ( $count->publish ?? 0 ) + ( $count->draft ?? 0 ) + ( $count->pending ?? 0 ) + ( $count->trash ?? 0 ) + 1; return sprintf( 'BNB-%s-%05d', $year, $total ); } /** * Calculate number of nights between two dates. * * @param string $check_in Check-in date (Y-m-d). * @param string $check_out Check-out date (Y-m-d). * @return int */ public static function calculate_nights( string $check_in, string $check_out ): int { $start = new \DateTimeImmutable( $check_in ); $end = new \DateTimeImmutable( $check_out ); return max( 1, (int) $start->diff( $end )->days ); } /** * Check if dates conflict with existing bookings. * * @param int $room_id Room post ID. * @param string $check_in Check-in date (Y-m-d). * @param string $check_out Check-out date (Y-m-d). * @param int|null $exclude_id Booking ID to exclude (for editing). * @return bool */ public static function has_conflict( int $room_id, string $check_in, string $check_out, ?int $exclude_id = null ): bool { $args = array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'meta_query' => array( 'relation' => 'AND', array( 'key' => self::META_PREFIX . 'room_id', 'value' => $room_id, ), array( 'key' => self::META_PREFIX . 'status', 'value' => 'cancelled', 'compare' => '!=', ), // Overlap detection: existing.check_in < new.check_out AND existing.check_out > new.check_in. array( 'key' => self::META_PREFIX . 'check_in', 'value' => $check_out, 'compare' => '<', 'type' => 'DATE', ), array( 'key' => self::META_PREFIX . 'check_out', 'value' => $check_in, 'compare' => '>', 'type' => 'DATE', ), ), ); if ( $exclude_id ) { $args['post__not_in'] = array( $exclude_id ); } $conflicts = get_posts( $args ); return ! empty( $conflicts ); } /** * Get room for a booking. * * @param int $booking_id Booking post ID. * @return \WP_Post|null */ public static function get_room( int $booking_id ): ?\WP_Post { $room_id = get_post_meta( $booking_id, self::META_PREFIX . 'room_id', true ); if ( ! $room_id ) { return null; } return get_post( $room_id ); } /** * Get building for a booking (through room). * * @param int $booking_id Booking post ID. * @return \WP_Post|null */ public static function get_building( int $booking_id ): ?\WP_Post { $room = self::get_room( $booking_id ); if ( ! $room ) { return null; } return Room::get_building( $room->ID ); } /** * Get guest for a booking. * * Returns the linked Guest post if guest_id exists, or a stdClass object * with guest data from booking meta for backward compatibility. * * @param int $booking_id Booking post ID. * @return \WP_Post|\stdClass|null Guest post, virtual guest object, or null. */ public static function get_guest( int $booking_id ) { $guest_id = get_post_meta( $booking_id, self::META_PREFIX . 'guest_id', true ); // If linked to Guest CPT, return the guest post. if ( $guest_id ) { $guest = get_post( $guest_id ); if ( $guest && Guest::POST_TYPE === $guest->post_type ) { return $guest; } } // Otherwise, create a virtual guest object from booking meta. $guest_name = get_post_meta( $booking_id, self::META_PREFIX . 'guest_name', true ); $guest_email = get_post_meta( $booking_id, self::META_PREFIX . 'guest_email', true ); $guest_phone = get_post_meta( $booking_id, self::META_PREFIX . 'guest_phone', true ); if ( ! $guest_name && ! $guest_email ) { return null; } // Return virtual guest object for backward compatibility. $virtual_guest = new \stdClass(); $virtual_guest->ID = 0; $virtual_guest->post_type = 'virtual_guest'; $virtual_guest->post_title = $guest_name ?: ''; $virtual_guest->name = $guest_name ?: ''; $virtual_guest->email = $guest_email ?: ''; $virtual_guest->phone = $guest_phone ?: ''; return $virtual_guest; } /** * Get all bookings for a room. * * @param int $room_id Room post ID. * @param array $args Additional query args. * @return array<\WP_Post> */ public static function get_bookings_for_room( int $room_id, array $args = array() ): array { $defaults = array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, 'meta_query' => array( array( 'key' => self::META_PREFIX . 'room_id', 'value' => $room_id, ), ), 'orderby' => 'meta_value', 'meta_key' => self::META_PREFIX . 'check_in', 'order' => 'ASC', ); return get_posts( array_merge( $defaults, $args ) ); } /** * Calculate total services cost for a booking. * * @param int $booking_id Booking post ID. * @return float Total services cost. */ public static function calculate_booking_services_total( int $booking_id ): float { $services = get_post_meta( $booking_id, self::SERVICES_META_KEY, true ); if ( ! is_array( $services ) || empty( $services ) ) { return 0.0; } $total = 0.0; foreach ( $services as $service ) { if ( isset( $service['price'] ) ) { $total += (float) $service['price']; } } return $total; } /** * Get selected services for a booking. * * @param int $booking_id Booking post ID. * @return array Array of service data with names. */ public static function get_booking_services( int $booking_id ): array { $services = get_post_meta( $booking_id, self::SERVICES_META_KEY, true ); if ( ! is_array( $services ) || empty( $services ) ) { return array(); } $result = array(); foreach ( $services as $service ) { $service_post = get_post( $service['service_id'] ?? 0 ); if ( $service_post ) { $result[] = array_merge( $service, array( 'name' => $service_post->post_title ) ); } } return $result; } /** * Format price breakdown for display. * * @param array $breakdown Price breakdown array. * @return string HTML output. */ private static function format_price_breakdown( array $breakdown ): string { $output = ''; return $output; } }