booking_repo = $booking_repo ?: new BookingRepository(); $this->customer_repo = $customer_repo ?: new CustomerRepository(); $this->payment_repo = $payment_repo ?: new PaymentRepository(); $this->slot_allocator = $slot_allocator ?: new SlotAllocator( $this->booking_repo ); } /** * Create a new booking from submitted payload. * * @param array $payload Form data from frontend or admin. * @param string $source 'frontend' or 'admin'. * @return array{success: bool, booking_id?: int, booking_ref?: string, message?: string, errors?: array, status?: int, code?: string, conflicts?: array} */ public function create_booking( array $payload, string $source = 'frontend' ): array { $service_id = absint( $payload['service_id'] ?? 0 ); $service = ServicePostType::get_service_data( $service_id ); $is_admin_source = ( 'admin' === $source ); if ( empty( $service ) || ( ! $is_admin_source && 'active' !== $service['status'] ) ) { return [ 'success' => false, 'message' => __( 'Please select a valid cleaning service.', 'cleannetic' ), ]; } // Validate customer data. $first_name = sanitize_text_field( $payload['first_name'] ?? '' ); $last_name = sanitize_text_field( $payload['last_name'] ?? '' ); $email = sanitize_email( $payload['email'] ?? '' ); $phone = trim( sanitize_text_field( $payload['phone'] ?? '' ) ); $address = sanitize_textarea_field( $payload['address'] ?? '' ); $city = sanitize_text_field( $payload['city'] ?? '' ); $zip = sanitize_text_field( $payload['zip'] ?? '' ); $gate_code = sanitize_text_field( $payload['gate_code'] ?? '' ); $notes = sanitize_textarea_field( $payload['notes'] ?? '' ); $errors = []; if ( empty( $first_name ) ) { $errors['first_name'] = __( 'First name is required.', 'cleannetic' ); } if ( empty( $email ) || ! is_email( $email ) ) { $errors['email'] = __( 'A valid email address is required.', 'cleannetic' ); } if ( empty( $phone ) ) { $errors['phone'] = __( 'Phone number is required.', 'cleannetic' ); } elseif ( ! preg_match( '/^[0-9\s\+\-\(\)\.]+$/', $phone ) || strlen( preg_replace( '/\D/', '', $phone ) ) < 7 ) { $errors['phone'] = __( 'Please enter a valid phone number.', 'cleannetic' ); } if ( empty( $address ) ) { $errors['address'] = __( 'Service address is required.', 'cleannetic' ); } // Validate date and time. $date = sanitize_text_field( $payload['date'] ?? '' ); $time = sanitize_text_field( $payload['time'] ?? '' ); $is_quote_service = ( 'quote' === $service['mode'] ); $has_valid_date = (bool) preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) && \DateTime::createFromFormat( 'Y-m-d', $date ); $has_valid_time = (bool) preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', $time ); // Quote requests may carry a preferred date/time; fixed-price bookings require one. if ( ! $is_quote_service ) { if ( ! $has_valid_date ) { $errors['date'] = __( 'Please choose a valid service date.', 'cleannetic' ); } if ( ! $has_valid_time ) { $errors['time'] = __( 'Please select a time slot.', 'cleannetic' ); } } if ( ! empty( $errors ) ) { return [ 'success' => false, 'message' => __( 'Please correct the highlighted fields.', 'cleannetic' ), 'errors' => $errors, ]; } // Pricing calculation. $pricing = PricingEngine::calculate( [ 'service_id' => $service_id, 'bedrooms' => absint( $payload['bedrooms'] ?? 0 ), 'bathrooms' => (float) ( $payload['bathrooms'] ?? 0 ), 'sqft' => absint( $payload['sqft'] ?? 0 ), 'windows' => absint( $payload['windows'] ?? 0 ), 'hours' => (float) ( $payload['hours'] ?? 0 ), 'addons' => (array) ( $payload['addons'] ?? [] ), 'addon_qtys' => (array) ( $payload['addon_qtys'] ?? [] ), 'frequency' => sanitize_text_field( $payload['frequency'] ?? 'once' ), 'zip' => $zip, 'coupon_code' => sanitize_text_field( $payload['coupon_code'] ?? '' ), 'email' => $email, ] ); $duration_minutes = (int) ( $pricing['duration_minutes'] ?? $service['base_duration'] ); // Slot verification for non-admin requests. if ( 'frontend' === $source && ! $is_quote_service ) { $slots_result = $this->slot_allocator->get_slots_for_date( $service_id, $date, $duration_minutes ); $slot_found = false; foreach ( $slots_result['slots'] as $slot ) { if ( $slot['time'] === $time && $slot['available'] ) { $slot_found = true; break; } } if ( ! $slot_found ) { return [ 'success' => false, 'message' => __( 'The selected time slot is no longer available. Please choose another time.', 'cleannetic' ), ]; } } // Admin manual booking conflict check. if ( $is_admin_source && ! $is_quote_service && $has_valid_date && $has_valid_time ) { $conflict = $this->check_conflict( $service_id, $date, $time, $duration_minutes ); if ( $conflict && empty( $payload['override'] ) ) { return [ 'success' => false, 'status' => 409, 'code' => 'booking_conflict', 'message' => $conflict['message'], 'conflicts' => $conflict['conflicts'], ]; } } // Upsert customer. $customer_id = $this->customer_repo->upsert( [ 'first_name' => $first_name, 'last_name' => $last_name, 'email' => $email, 'phone' => $phone, 'address' => $address, 'gate_code' => $gate_code, 'property_notes' => $notes, ] ); if ( ! $customer_id ) { return [ 'success' => false, 'message' => __( 'Could not save customer information.', 'cleannetic' ), ]; } // Datetime bounds. if ( $has_valid_date && $has_valid_time ) { $start_datetime = $date . ' ' . $time . ':00'; $start_dt = \DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', $start_datetime, wp_timezone() ); $end_dt = $start_dt ? $start_dt->modify( "+{$duration_minutes} minutes" ) : null; $end_datetime = $end_dt ? $end_dt->format( 'Y-m-d H:i:s' ) : $start_datetime; } else { $start_datetime = current_time( 'mysql' ); $end_datetime = $start_datetime; } $booking_ref = $this->generate_unique_ref(); $default_status = $is_quote_service ? BookingStatus::QUOTE_REQUESTED : Settings::get( 'default_status', BookingStatus::PENDING ); $payment_method = sanitize_key( (string) ( $payload['payment_method'] ?? 'cash' ) ); if ( ! in_array( $payment_method, (array) apply_filters( 'cleannetic_payment_methods', [ 'cash' ] ), true ) ) { $payment_method = 'cash'; } $frequency = (string) $pricing['frequency']; $address_data = [ 'street' => $address, 'city' => $city, 'zip' => $zip, 'gate_code' => $gate_code, ]; $booking_row = (array) apply_filters( 'cleannetic_before_insert_booking', [ 'booking_ref' => $booking_ref, 'customer_id' => $customer_id, 'service_id' => $service_id, 'staff_id' => $is_admin_source && ! empty( $payload['staff_id'] ) ? absint( $payload['staff_id'] ) : null, 'start_datetime' => $start_datetime, 'end_datetime' => $end_datetime, 'duration_minutes' => $duration_minutes, 'status' => $default_status, 'pricing_breakdown' => $pricing, 'total_amount' => (float) $pricing['final_price'], 'deposit_paid' => 0.00, 'payment_status' => BookingStatus::PAYMENT_UNPAID, 'payment_method' => $payment_method, 'is_recurring' => 'once' !== $frequency ? 1 : 0, 'recurrence_cycle' => 'once' !== $frequency ? $frequency : null, 'address_data' => $address_data, 'notes' => $notes, 'admin_notes' => $is_admin_source ? sanitize_textarea_field( $payload['admin_notes'] ?? '' ) : '', 'source' => $source, ], $payload, $pricing, $service ); $booking_id = $this->booking_repo->insert( $booking_row ); if ( ! $booking_id ) { return [ 'success' => false, 'message' => __( 'Could not create booking record. Please try again.', 'cleannetic' ), ]; } // Create initial transaction record. $this->payment_repo->insert( [ 'booking_id' => $booking_id, 'transaction_id' => 'INIT-' . $booking_ref, 'gateway' => $payment_method, 'amount' => (float) $pricing['final_price'], 'currency' => Settings::get( 'currency_code', 'USD' ), 'payment_type' => 'full', 'status' => 'pending', 'notes' => __( 'Payment due on site upon service completion.', 'cleannetic' ), ] ); $booking = $this->booking_repo->find( $booking_id ); /** * Action fired after booking is created. * * @param int $booking_id * @param array $booking */ do_action( 'cleannetic_booking_created', $booking_id, $booking ); return [ 'success' => true, 'booking_id' => $booking_id, 'booking_ref' => $booking_ref, 'message' => $is_quote_service ? __( 'Your quote request has been received. We will contact you shortly with a final price.', 'cleannetic' ) : Settings::get( 'success_message', __( 'Your booking has been received!', 'cleannetic' ) ), 'booking' => $booking, ]; } /** * Check if a proposed slot conflicts with working hours or existing non-cancelled bookings. * * @param int $service_id Service post ID. * @param string $date Y-m-d date string. * @param string $time H:i time string. * @param int $duration Duration in minutes. * @return array{has_conflict: bool, message: string, conflicts: string[]}|null */ public function check_conflict( int $service_id, string $date, string $time, int $duration = 0 ): ?array { $parsed_date = \DateTimeImmutable::createFromFormat( 'Y-m-d', $date, wp_timezone() ); if ( ! $parsed_date || $parsed_date->format( 'Y-m-d' ) !== $date ) { return [ 'has_conflict' => true, 'message' => __( 'Invalid date format. Expected Y-m-d.', 'cleannetic' ), 'conflicts' => [], ]; } if ( ! preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', $time ) ) { return [ 'has_conflict' => true, 'message' => __( 'Invalid time format. Expected HH:MM.', 'cleannetic' ), 'conflicts' => [], ]; } if ( $duration <= 0 ) { $service = ServicePostType::get_service_data( $service_id ); $duration = (int) ( $service['base_duration'] ?? 60 ); } [ $slot_h, $slot_m ] = array_map( 'intval', explode( ':', $time ) ); $slot_start = $parsed_date->setTime( $slot_h, $slot_m, 0 ); $slot_end = $slot_start->modify( "+{$duration} minutes" ); // 1. Check blocked dates. $blocked_dates = (array) Settings::get( 'blocked_dates', [] ); if ( in_array( $date, $blocked_dates, true ) ) { return [ 'has_conflict' => true, 'message' => __( 'Business is closed on this date (Holiday/Closed).', 'cleannetic' ), 'conflicts' => [], ]; } // 2. Check working hours. $weekday_map = [ 1 => 'mon', 2 => 'tue', 3 => 'wed', 4 => 'thu', 5 => 'fri', 6 => 'sat', 7 => 'sun' ]; $day_num = (int) $parsed_date->format( 'N' ); $day_key = $weekday_map[ $day_num ] ?? 'mon'; $working_hours = (array) Settings::get( 'working_hours', [] ); $day_config = $working_hours[ $day_key ] ?? null; if ( empty( $day_config['enabled'] ) || empty( $day_config['start'] ) || empty( $day_config['end'] ) ) { return [ 'has_conflict' => true, 'message' => __( 'Business is closed on this day.', 'cleannetic' ), 'conflicts' => [], ]; } [ $start_h, $start_m ] = array_map( 'intval', explode( ':', $day_config['start'] ) ); [ $end_h, $end_m ] = array_map( 'intval', explode( ':', $day_config['end'] ) ); $day_start_dt = $parsed_date->setTime( $start_h, $start_m, 0 ); $day_end_dt = $parsed_date->setTime( $end_h, $end_m, 0 ); if ( $slot_start < $day_start_dt || $slot_end > $day_end_dt ) { return [ 'has_conflict' => true, 'message' => sprintf( /* translators: 1: opening time, 2: closing time */ __( 'The selected time is outside working hours (%1$s–%2$s).', 'cleannetic' ), $day_config['start'], $day_config['end'] ), 'conflicts' => [], ]; } // 3. Check overlaps with existing bookings. $buffer_minutes = max( 0, (int) Settings::get( 'buffer_minutes', 30 ) ); $conflicts = $this->booking_repo->find_conflicts( $slot_start->format( 'Y-m-d H:i:s' ), $slot_end->format( 'Y-m-d H:i:s' ), $buffer_minutes ); if ( ! empty( $conflicts ) ) { $conflict_details = []; $conflict_refs = []; foreach ( $conflicts as $c ) { $c_start = new \DateTimeImmutable( $c['start_datetime'], wp_timezone() ); $c_dur = (int) ( $c['duration_minutes'] ?: 60 ); $c_end = ( ! empty( $c['end_datetime'] ) && '0000-00-00 00:00:00' !== $c['end_datetime'] ) ? new \DateTimeImmutable( $c['end_datetime'], wp_timezone() ) : $c_start->modify( "+{$c_dur} minutes" ); $ref = $c['booking_ref']; $time_range = $c_start->format( 'H:i' ) . '–' . $c_end->format( 'H:i' ); $conflict_details[] = "{$ref} ({$time_range})"; $conflict_refs[] = $ref; } return [ 'has_conflict' => true, 'message' => sprintf( /* translators: %s: list of conflicting bookings */ __( 'This time overlaps with %s', 'cleannetic' ), implode( ', ', $conflict_details ) ), 'conflicts' => $conflict_refs, ]; } return apply_filters( 'cleannetic_booking_conflict', null, $service_id, $date, $time, $duration ); } /** * Transition a booking's status safely. */ public function update_status( int $booking_id, string $new_status ): bool { if ( ! BookingStatus::is_valid( $new_status ) ) { return false; } $booking = $this->booking_repo->find( $booking_id ); if ( ! $booking ) { return false; } $old_status = $booking['status']; if ( $old_status === $new_status ) { return true; } $updated = $this->booking_repo->update( $booking_id, [ 'status' => $new_status ] ); if ( $updated ) { do_action( 'cleannetic_booking_status_changed', $booking_id, $new_status, $old_status ); } return $updated; } public function update_payment_status( int $booking_id, string $new_payment_status ): bool { if ( ! BookingStatus::is_valid_payment( $new_payment_status ) ) { return false; } return $this->booking_repo->update( $booking_id, [ 'payment_status' => $new_payment_status ] ); } public function assign_staff( int $booking_id, int $staff_id ): bool { return $this->booking_repo->update( $booking_id, [ 'staff_id' => $staff_id ?: null ] ); } private function generate_unique_ref(): string { $prefix = 'CLN-'; do { $random = strtoupper( wp_generate_password( 6, false, false ) ); $ref = $prefix . $random; } while ( $this->booking_repo->ref_exists( $ref ) ); return $ref; } }