# yatra/3.0.9/app/Services/BookingService.php

Yatra – Travel Booking &amp; Tour Operator Software, version 3.0.9. 1,245 lines.

- Page: https://pluginprobe.com/plugins/yatra/3.0.9/code/app/Services/BookingService.php
- Raw: https://pluginprobe.com/plugins/yatra/3.0.9/raw/app/Services/BookingService.php
- Modified: 2026-07-06T05:29:20+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/yatra/3.0.9/code/app/Services/BookingService.php#L10-L20`.

```php
<?php

declare(strict_types=1);

namespace Yatra\Services;

use Yatra\Repositories\BookingRepository;
use Yatra\Repositories\PaymentRepository;
use Yatra\Repositories\TravellerRepository;
use Yatra\Repositories\CustomerRepository;
use Yatra\Repositories\TripRepository;
use Yatra\Repositories\DepartureRepository;
use Yatra\Repositories\BookingDepartureRepository;
use Yatra\Validators\BookingValidator;
use Yatra\Utils\Logger;
use Yatra\Services\BookingTaxService;

/**
 * Booking Service
 * 
 * Contains business logic for bookings.
 * Uses repositories for data access.
 * 
 * Note: Does not extend BaseService as it uses multiple repositories
 * and has specialized booking-related methods.
 * 
 * @package Yatra\Services
 */
class BookingService
{
    private BookingRepository $bookingRepository;
    private PaymentRepository $paymentRepository;
    private TravellerRepository $travellerRepository;
    private CustomerRepository $customerRepository;
    private TripRepository $tripRepository;
    private DepartureService $departureService;

    public function __construct()
    {
        $this->bookingRepository = new BookingRepository();
        $this->paymentRepository = new PaymentRepository();
        $this->travellerRepository = new TravellerRepository();
        $this->customerRepository = new CustomerRepository();
        $this->tripRepository = new TripRepository();
        $this->departureService = new DepartureService(
            new DepartureRepository(),
            new BookingDepartureRepository(),
            $this->bookingRepository,
            $this->tripRepository
        );
    }

    /**
     * Get paginated bookings
     * 
     * @param array $filters Filters (page, per_page, status, search, etc.)
     * @return array
     */
    public function getBookings(array $filters = []): array
    {
        $result = $this->bookingRepository->paginate($filters);

        // Format each booking
        $result['data'] = array_map([$this, 'formatBooking'], $result['data']);

        return $result;
    }

    /**
     * Get single booking with all related data
     * 
     * @param int $id Booking ID
     * @return array|null
     */
    public function getBooking(int $id): ?array
    {
        $booking = $this->bookingRepository->findWithTrip($id);

        if (!$booking) {
            return null;
        }

        $formatted = $this->formatBookingWithDetails($booking);

        return $formatted;
    }

    /**
     * Get booking by reference code
     * 
     * @param string $reference Booking reference
     * @return array|null
     */
    public function getBookingByReference(string $reference): ?array
    {
        $booking = $this->bookingRepository->findByReference($reference);

        if (!$booking) {
            return null;
        }

        return $this->formatBookingWithDetails($booking);
    }

    /**
     * Validate booking business rules
     */
    private function validateBookingBusinessRules(array $data): array
    {
        // Check minimum travelers count
        $travelersCount = (int) ($data['travelers_count'] ?? 0);
        if ($travelersCount <= 0) {
            return [
                'success' => false,
                'message' => __('At least one traveler is required for booking.', 'yatra')
            ];
        }

        // Check trip capacity if specified
        if (!empty($data['trip_id'])) {
            $trip = $this->tripRepository->find((int) $data['trip_id']);
            if ($trip && !empty($trip->max_travelers)) {
                $maxCapacity = (int) $trip->max_travelers;
                if ($travelersCount > $maxCapacity) {
                    return [
                        'success' => false,
                        'message' => sprintf(
                            /* translators: %d: maximum number of travelers allowed. */
                            __('Maximum %d travelers allowed for this trip.', 'yatra'),
                            $maxCapacity
                        )
                    ];
                }
            }
        }

        // Check booking date is in the future
        if (!empty($data['start_date'])) {
            $startDate = strtotime($data['start_date']);
            $today = strtotime('today');
            
            if ($startDate < $today) {
                return [
                    'success' => false,
                    'message' => __('Booking date must be in the future.', 'yatra')
                ];
            }
        }

        // Check total amount is positive
        $totalAmount = (float) ($data['total_amount'] ?? 0);
        if ($totalAmount <= 0) {
            return [
                'success' => false,
                'message' => __('Total amount must be greater than zero.', 'yatra')
            ];
        }

        // Check payment amount doesn't exceed total
        $amountPaid = (float) ($data['amount_paid'] ?? 0);
        if ($amountPaid > $totalAmount) {
            return [
                'success' => false,
                'message' => __('Payment amount cannot exceed total booking amount.', 'yatra')
            ];
        }

        // Validate tax configuration
        $taxValidation = BookingTaxService::validateBookingTax($data);
        if (!$taxValidation['valid']) {
            return [
                'success' => false,
                'message' => __('Tax configuration error.', 'yatra'),
                'errors' => $taxValidation['errors']
            ];
        }

        return ['success' => true];
    }

    /**
     * Create a new booking with comprehensive validation and business rules
     * 
     * @param array $data Booking data
     * @return array {success: bool, booking_id?: int, reference?: string, message?: string}
     */
    public function createBooking(array $data): array
    {
        $startTime = microtime(true);

        // Checkout defers the rich confirmation to BookingSessionController (avoids duplicate customer emails).
        // Not persisted; stripped by BookingValidator::sanitize().
        $skipInitialCustomerConfirmation = !empty($data['skip_initial_customer_confirmation']);
        
        try {
            Logger::info("Booking creation started", [
                'data_keys' => array_keys($data),
                'trip_id' => $data['trip_id'] ?? null,
                'has_itinerary_costs' => isset($data['itinerary_costs']),
                'has_itinerary_costs_total' => isset($data['itinerary_costs_total'])
            ]);
            
            // Comprehensive validation using BookingValidator
            try {
                BookingValidator::validateCreate($data);
            } catch (\Yatra\Exceptions\ValidationException $e) {
                Logger::warning('Booking validation failed', [
                    'trip_id' => $data['trip_id'] ?? null,
                    'errors' => $e->getErrors(),
                ]);
                return [
                    'success' => false,
                    'message' => $e->getMessage() ?: __('Booking validation failed.', 'yatra'),
                    'errors' => $e->getErrors(),
                ];
            }
            
            $data = BookingValidator::sanitize($data);

            // ========================================
            // SERVER-SIDE AVAILABILITY RESOLUTION (single source of truth)
            // ========================================
            // For rule-generated dates we may not have a numeric availability_id. Always re-resolve
            // by (trip_id, travel_date, departure_time) so capacity/status/cutoff checks match what
            // the single-trip UI showed.
            $isWaitlist = isset($data['status']) && $data['status'] === 'waitlist';
            $tripId = (int) ($data['trip_id'] ?? 0);
            $travelDate = (string) ($data['travel_date'] ?? ($data['start_date'] ?? ''));
            $departureTime = null;
            if (!empty($data['departure_time']) && is_string($data['departure_time'])) {
                $departureTime = trim($data['departure_time']);
                if ($departureTime === '') {
                    $departureTime = null;
                }
            }

            if (!$isWaitlist && $tripId > 0 && $travelDate !== '') {
                try {
                    $resolver = new AvailabilityResolutionService();
                    $resolved = $resolver->resolveAvailabilityForDate($tripId, $travelDate, $departureTime);

                    $status = (string) ($resolved->status ?? 'available');
                    if (in_array($status, ['blocked', 'closed', 'cancelled'], true)) {
                        return [
                            'success' => false,
                            'message' => __('This departure is not open for booking.', 'yatra'),
                        ];
                    }

                    $travelersCount = (int) ($data['travelers_count'] ?? 0);
                    $seatsAvailable = isset($resolved->seats_available) ? (int) $resolved->seats_available : null;
                    if ($status === 'sold_out' || ($seatsAvailable !== null && $seatsAvailable <= 0)) {
                        return [
                            'success' => false,
                            'message' => __('This departure is sold out.', 'yatra'),
                        ];
                    }
                    if ($seatsAvailable !== null && $travelersCount > 0 && $travelersCount > $seatsAvailable) {
                        return [
                            'success' => false,
                            'message' => __('This departure is full.', 'yatra'),
                        ];
                    }

                    // Cutoff enforcement (best-effort; recurring generation already filters by cutoff).
                    $cutoffHours = isset($resolved->cutoff_hours) ? (int) $resolved->cutoff_hours : 0;
                    if ($cutoffHours > 0 && !empty($resolved->departure_date)) {
                        $dt = (string) $resolved->departure_date;
                        if (!empty($resolved->departure_time)) {
                            $dt .= ' ' . (string) $resolved->departure_time;
                        } else {
                            $dt .= ' 00:00';
                        }
                        $depTs = strtotime($dt);
                        if ($depTs !== false) {
                            $latest = $depTs - ($cutoffHours * 3600);
                            if (time() > $latest) {
                                return [
                                    'success' => false,
                                    'message' => __('Booking cutoff has passed for this departure.', 'yatra'),
                                ];
                            }
                        }
                    }

                    // Persist a snapshot of the resolved source for auditing and downstream modules.
                    $meta = [];
                    if (!empty($data['meta']) && is_string($data['meta'])) {
                        $decoded = json_decode($data['meta'], true);
                        $meta = is_array($decoded) ? $decoded : [];
                    } elseif (is_array($data['meta'] ?? null)) {
                        $meta = $data['meta'];
                    }
                    $meta['resolved_availability'] = [
                        'source' => $resolved->source ?? null,
                        'rule_id' => $resolved->rule_id ?? null,
                        'availability_id' => $resolved->id ?? null,
                        'departure_date' => $resolved->departure_date ?? null,
                        'departure_time' => $resolved->departure_time ?? null,
                        'pricing_type' => $resolved->pricing_type ?? null,
                        'price_types' => $resolved->price_types ?? [],
                    ];
                    $data['meta'] = wp_json_encode($meta);
                } catch (\Throwable $e) {
                    // If resolution fails, allow booking to proceed using legacy validations.
                }
            }
            
            Logger::info("After BookingValidator::sanitize", [
                'data_keys' => array_keys($data),
                'has_itinerary_costs' => isset($data['itinerary_costs']),
                'has_itinerary_costs_total' => isset($data['itinerary_costs_total'])
            ]);
            
            // Business rule validations
            $validationResult = $this->validateBookingBusinessRules($data);
            if (!$validationResult['success']) {
                Logger::warning("Booking business rule validation failed", [
                    'trip_id' => $data['trip_id'],
                    'reason' => $validationResult['message']
                ]);
                return $validationResult;
            }
            
            // Validate trip exists and is available
            $trip = $this->tripRepository->find((int) $data['trip_id']);
            if (!$trip) {
                Logger::error("Trip not found for booking", ['trip_id' => $data['trip_id']]);
                return ['success' => false, 'message' => __('Trip not found.', 'yatra')];
            }

            if ($trip->status !== 'publish') {
                Logger::warning("Trip not available for booking", [
                    'trip_id' => $data['trip_id'],
                    'status' => $trip->status
                ]);
                return ['success' => false, 'message' => __('Trip is not available for booking.', 'yatra')];
            }

            // Calculate start_date and end_date if travel_date is provided
            if (!empty($data['travel_date']) && empty($data['start_date'])) {
                $data['start_date'] = $data['travel_date'];
            }
            
            if (!empty($data['start_date']) && empty($data['end_date'])) {
                $data['end_date'] = $this->calculateEndDate($data['start_date'], (int) $data['trip_id']);
            }

            // Generate unique reference
            $data['reference'] = $this->bookingRepository->generateReference();

            // Find or create customer
            if (!empty($data['contact_email'])) {
                $customerId = $this->customerRepository->findOrCreate([
                    'email' => $data['contact_email'],
                    'first_name' => $data['contact_first_name'] ?? '',
                    'last_name' => $data['contact_last_name'] ?? '',
                    'phone' => $data['contact_phone'] ?? '',
                    'country' => $data['contact_country'] ?? '',
                    'user_id' => $data['user_id'] ?? null,
                ]);
                $data['customer_id'] = $customerId;
            }

            // Apply tax calculation to booking data
            $data = BookingTaxService::applyTaxToBooking($data);

            // Calculate amount due (recalculated after tax). Honor the selected
            // payment method via Pro FlexiblePayments so a deposit/partial
            // booking stores the reduced amount due now — not the full total.
            // For 'full' (and when Pro is inactive) the filter returns
            // total − paid unchanged, so full-payment bookings are unaffected.
            // After the deposit is paid, payment completion resets amount_due to
            // the remaining balance (total − amount_paid).
            $bs_total          = (float) ($data['total_amount'] ?? 0);
            $bs_paid           = (float) ($data['amount_paid'] ?? 0);
            $bs_payment_method = strtolower(trim((string) ($data['payment_method'] ?? 'full')));
            $bs_due_now        = (float) apply_filters(
                'yatra_calculate_amount_due',
                $bs_total - $bs_paid,
                $bs_total,
                $bs_payment_method,
                ['trip_id' => (int) ($data['trip_id'] ?? 0)]
            );
            $data['amount_due'] = max(0.0, round($bs_due_now, 2));

            // Create booking
            $bookingId = $this->bookingRepository->create($data);

            if (!$bookingId) {
                Logger::error("Failed to create booking in database", ['data' => $data]);
                return ['success' => false, 'message' => __('Failed to create booking.', 'yatra')];
            }

            // Waitlist bookings do not consume departure capacity until promoted.
            $isWaitlist = isset($data['status']) && $data['status'] === 'waitlist';

            // Link booking to departure if start_date is provided
            if (!$isWaitlist && !empty($data['start_date']) && !empty($data['end_date'])) {
                try {
                    $trip = $this->tripRepository->find((int) $data['trip_id']);
                    // Get max capacity from trip's max_travelers, or use default
                    $maxCapacity = null;
                    if ($trip && !empty($trip->max_travelers)) {
                        $maxCapacity = (int) $trip->max_travelers;
                    }
                    $travelersCount = (int) ($data['travelers_count'] ?? 0);

                    $departureTime = null;
                    if (!empty($data['departure_time']) && is_string($data['departure_time'])) {
                        $departureTime = trim($data['departure_time']);
                        if ($departureTime === '') {
                            $departureTime = null;
                        }
                    }

                    // Find or create departure
                    $departure = $this->departureService->findOrCreateForBooking(
                        (int) $data['trip_id'],
                        $data['start_date'],
                        $data['end_date'],
                        $travelersCount,
                        $maxCapacity,
                        $departureTime
                    );

                    // Link booking to departure
                    $this->departureService->linkBookingToDeparture($bookingId, $departure->id);

                    // Increment booked count
                    $this->departureService->incrementBookedCount($departure->id, $travelersCount);

                    Logger::info("Booking linked to departure", [
                        'booking_id' => $bookingId,
                        'departure_id' => $departure->id
                    ]);
                } catch (\Exception $e) {
                    // Log error but don't fail the booking
                    Logger::warning("Failed to link booking to departure", [
                        'booking_id' => $bookingId,
                        'error' => $e->getMessage()
                    ]);
                }
            }

            // Save travelers
            if (!empty($data['travelers']) && is_array($data['travelers'])) {
                $this->saveTravelers($bookingId, $data['travelers']);
            }

            // Customer confirmation: skip when checkout will send the session email (offline / zero due).
            if (!$skipInitialCustomerConfirmation) {
                $this->sendBookingConfirmationEmail($bookingId);
            }
            
            $executionTime = microtime(true) - $startTime;
            Logger::info("Booking created successfully", [
                'booking_id' => $bookingId,
                'reference' => $data['reference'],
                'execution_time' => $executionTime
            ]);

            $booking = $this->bookingRepository->find((int) $bookingId);
            if (!is_object($booking)) {
                $booking = (object) [];
            }

            // Defer the public booking-created action when the row is
            // still in `pending_verification`. Sending the booking
            // confirmation email and firing analytics integrations
            // before the customer has proven the email is theirs would
            // (a) leak the booking details to whoever owns that
            // address, and (b) inflate conversion metrics with bookings
            // that may never be verified. BookingSessionController::
            // verify_email() re-fires this action after the status flip
            // so every listener (NotificationHooks, EmailAutomation,
            // analytics modules) still runs — just *after* verification.
            //
            // Inventory + cache invalidation aren't routed through this
            // action (they're called directly above), so seat-holding
            // continues to work while the customer is in the holding
            // state.
            $bookingStatus = (string) ($data['status'] ?? ($booking->status ?? ''));
            if ($bookingStatus !== 'pending_verification') {
                do_action(\Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED, (int) $bookingId, $booking);
            }

            return [
                'success' => true,
                'booking_id' => $bookingId,
                'reference' => $data['reference'],
                'message' => __('Booking created successfully.', 'yatra'),
            ];
            
        } catch (\Exception $e) {
            $executionTime = microtime(true) - $startTime;
            Logger::error("Booking creation failed", [
                'trip_id' => $data['trip_id'] ?? null,
                'execution_time' => $executionTime,
                'error' => $e->getMessage()
            ]);
            
            return [
                'success' => false,
                'message' => $e->getMessage()
            ];
        }
    }

    /**
     * Update a booking
     * 
     * @param int   $id   Booking ID
     * @param array $data Booking data
     * @return array {success: bool, message: string}
     */
    public function updateBooking(int $id, array $data): array
    {
        $booking = $this->bookingRepository->find($id);

        if (!$booking) {
            return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
        }

        // Check if date is being changed
        $oldStartDate = $booking->start_date ?? $booking->travel_date ?? null;
        $newStartDate = $data['start_date'] ?? $data['travel_date'] ?? null;
        $dateChanged = false;

        if ($newStartDate && $oldStartDate && $newStartDate !== $oldStartDate) {
            $dateChanged = true;
        }

        // Calculate end_date if start_date is provided
        if (!empty($data['start_date']) && empty($data['end_date'])) {
            $data['end_date'] = $this->calculateEndDate($data['start_date'], (int) $booking->trip_id);
        } elseif (!empty($data['travel_date']) && empty($data['start_date']) && empty($data['end_date'])) {
            $data['start_date'] = $data['travel_date'];
            $data['end_date'] = $this->calculateEndDate($data['travel_date'], (int) $booking->trip_id);
        }

        $oldStatus = (string) ($booking->status ?? '');
        $oldPaymentStatus = (string) ($booking->payment_status ?? '');

        // Update booking
        $updated = $this->bookingRepository->update($id, $data);

        if (!$updated) {
            return ['success' => false, 'message' => __('Failed to update booking.', 'yatra')];
        }

        $newStatus = isset($data['status']) ? (string) $data['status'] : null;
        if ($newStatus !== null && $oldStatus === 'waitlist' && $newStatus !== 'waitlist') {
            WaitlistService::releaseWaitlistHolding($booking);
        }

        // Handle departure date change if date was changed
        if ($dateChanged && !empty($data['start_date']) && !empty($data['end_date'])) {
            try {
                $this->departureService->handleBookingDateChange(
                    $id,
                    $data['start_date'],
                    $data['end_date']
                );
                } catch (\Exception $e) {
                // Log error but don't fail the update
                }
        }

        // Update travelers if provided
        if (isset($data['travelers']) && is_array($data['travelers'])) {
            // Delete existing travelers
            $this->travellerRepository->deleteByBookingId($id);
            // Save new travelers
            $this->saveTravelers($id, $data['travelers']);
        }

        // A manual payment-status change (e.g. an admin marking an offline
        // bank-transfer booking as Paid) fired no notification and no hook before,
        // so the customer was never told their payment was received. Detect the
        // change and notify — without firing `yatra_payment_completed` (that means
        // a real gateway charge and carries capture side effects).
        $newPaymentStatus = isset($data['payment_status']) ? (string) $data['payment_status'] : null;
        if ($newPaymentStatus !== null && $newPaymentStatus !== $oldPaymentStatus) {
            $this->handlePaymentStatusChange($id, $oldPaymentStatus, $newPaymentStatus);
        }

        // Return the fresh booking so the REST controller's `$result['data']`
        // is defined (previously absent → "Undefined array key data" warning).
        return [
            'success' => true,
            'message' => __('Booking updated successfully.', 'yatra'),
            'data' => $this->bookingRepository->find($id),
        ];
    }

    /**
     * React to a manual payment-status change (admin edits, e.g. bank transfer
     * marked Paid). Sends the customer + admin payment emails when money is
     * (fully or partially) received, and fires `yatra_payment_status_changed`
     * so integrations can react. Intentionally separate from
     * `yatra_payment_completed`, which represents a real gateway capture.
     */
    private function handlePaymentStatusChange(int $bookingId, string $oldStatus, string $newStatus): void
    {
        $booking = $this->bookingRepository->findWithTrip($bookingId);
        if (!$booking) {
            return;
        }

        do_action('yatra_payment_status_changed', $bookingId, $oldStatus, $newStatus, $booking);

        if (in_array($newStatus, ['paid', 'partial'], true)) {
            $paidAmount = (float) ($booking->amount_paid ?? 0);
            if ($paidAmount <= 0) {
                $paidAmount = (float) ($booking->total_amount ?? 0);
            }
            \Yatra\Services\NotificationService::sendPaymentCompletedNotification([
                'booking_id' => $bookingId,
                'amount' => $paidAmount,
                'payment_method' => (string) ($booking->payment_method ?? ''),
                'transaction_id' => '',
            ]);
        }
    }

    /**
     * Calculate end date from start date and trip duration
     * 
     * @param string $startDate Start date (YYYY-MM-DD)
     * @param int $tripId Trip ID
     * @return string End date (YYYY-MM-DD)
     */
    private function calculateEndDate(string $startDate, int $tripId): string
    {
        return $this->bookingRepository->calculateEndDate($startDate, $tripId);
    }

    /**
     * Update booking status
     * 
     * @param int    $id     Booking ID
     * @param string $status New status
     * @return array {success: bool, message: string}
     */
    public function updateStatus(int $id, string $status): array
    {
        $validStatuses = ['pending', 'confirmed', 'processing', 'completed', 'cancelled', 'refunded', 'failed', 'on_hold'];

        if (!in_array($status, $validStatuses, true)) {
            return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
        }

        $booking = $this->bookingRepository->find($id);

        if (!$booking) {
            return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
        }

        $oldStatus = $booking->status;
        $updated = $this->bookingRepository->updateStatus($id, $status);

        if (!$updated) {
            return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
        }

        if ($oldStatus === 'waitlist' && $status !== 'waitlist') {
            WaitlistService::releaseWaitlistHolding($booking);
        }

        // ========================================
        // HANDLE DEPARTURE BOOKED_COUNT UPDATE
        // ========================================
        // If booking is cancelled or refunded, unlink from departure and decrement booked_count
        // If booking status changes from cancelled/refunded to active, link and increment booked_count
        try {
            $departure = $this->departureService->getDepartureForBooking($id);
            $travelersCount = (int) ($booking->travelers_count ?? 0);

            if ($departure) {
                // If booking is being cancelled or refunded
                if (in_array($status, ['cancelled', 'refunded'], true) && 
                    !in_array($oldStatus, ['cancelled', 'refunded'], true)) {
                    // Unlink booking from departure (this will handle cancellation if no bookings remain)
                    $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
                    }
                // If booking status changes from cancelled/refunded back to active
                elseif (in_array($oldStatus, ['cancelled', 'refunded'], true) && 
                        !in_array($status, ['cancelled', 'refunded'], true)) {
                    // Ensure booking is linked and increment booked count
                    $this->departureService->linkBookingToDeparture($id, $departure->id);
                    $this->departureService->incrementBookedCount($departure->id, $travelersCount);
                    }
            } elseif (!empty($booking->start_date) || !empty($booking->travel_date)) {
                // Booking doesn't have a departure yet, but has a date - create and link
                $startDate = $booking->start_date ?? $booking->travel_date;
                $endDate = $booking->end_date ?? $this->calculateEndDate($startDate, (int) $booking->trip_id);
                
                $trip = $this->tripRepository->find((int) $booking->trip_id);
                $maxCapacity = $trip ? ($trip->max_capacity ?? 9999) : 9999;
                
                $departure = $this->departureService->findOrCreateForBooking(
                    (int) $booking->trip_id,
                    $startDate,
                    $endDate,
                    $travelersCount,
                    $maxCapacity
                );
                
                $this->departureService->linkBookingToDeparture($id, $departure->id);
                $this->departureService->incrementBookedCount($departure->id, $travelersCount);
                }
        } catch (\Exception $e) {
            // Log error but don't fail the status update
            }

        // Send status change notification
        $this->sendStatusChangeNotification($id, $oldStatus, $status);
        
        /**
         * Action: Booking status changed
         * Fires when booking status changes
         * 
         * @param int $id The booking ID
         * @param string $oldStatus Previous status
         * @param string $status New status
         * @since 3.0.0
         */
        do_action('yatra_booking_status_changed', $id, $oldStatus, $status);

        if ($status === 'confirmed' && $oldStatus !== 'confirmed') {
            \yatra_trigger_booking_confirmed($id, $oldStatus);
        }

        return [
            'success' => true,
            'message' => sprintf(
                /* translators: %s: new booking status. */
                __('Booking status updated to %s.', 'yatra'),
                $status
            ),
        ];
    }

    /**
     * Delete a booking
     * 
     * @param int $id Booking ID
     * @return array {success: bool, message: string}
     */
    public function deleteBooking(int $id): array
    {
        $booking = $this->bookingRepository->find($id);

        if (!$booking) {
            return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
        }

        if (($booking->status ?? '') === 'waitlist') {
            WaitlistService::releaseWaitlistHolding($booking);
        }

        try {
            $departure = $this->departureService->getDepartureForBooking($id);
            if ($departure) {
                $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
            }
        } catch (\Throwable $e) {
            // Continue with delete
        }

        // Delete related travelers
        $this->travellerRepository->deleteByBookingId($id);

        // Delete booking
        $deleted = $this->bookingRepository->delete($id);

        if (!$deleted) {
            return ['success' => false, 'message' => __('Failed to delete booking.', 'yatra')];
        }

        if (!is_object($booking)) {
            $booking = (object) [];
        }

        do_action('yatra_booking_deleted', (int) $id, $booking);

        return [
            'success' => true,
            'message' => __('Booking deleted successfully.', 'yatra'),
        ];
    }

    /**
     * Get booking statistics
     * 
     * @return array
     */
    public function getStats(): array
    {
        return $this->bookingRepository->getStats();
    }

    /**
     * Get booking payments
     * 
     * @param int $bookingId Booking ID
     * @return array
     */
    public function getBookingPayments(int $bookingId): array
    {
        return $this->paymentRepository->findByBookingId($bookingId);
    }

    /**
     * Get booking travelers
     * 
     * @param int $bookingId Booking ID
     * @return array
     */
    public function getBookingTravelers(int $bookingId): array
    {
        return $this->travellerRepository->getByBookingId($bookingId);
    }

    /**
     * Format booking for API response
     * 
     * @param object $booking Raw booking data
     * @return array
     */
    private function formatBooking(object $booking): array
    {
        // Build customer name from contact fields
        $customerName = trim(
            ($booking->customer_first_name ?? $booking->contact_first_name ?? '') . ' ' . ($booking->customer_last_name ?? $booking->contact_last_name ?? '')
        ) ?: ($booking->customer_name ?? $booking->customer_email ?? $booking->contact_email ?? null);

        $customerEmail = $booking->customer_email ?? $booking->contact_email ?? null;
        $customerPhone = $booking->contact_phone ?? null;

        // Fallback: fetch customer record if customer_id is set and info missing
        if (($booking->customer_id ?? 0) && (empty($customerName) || empty($customerEmail) || empty($customerPhone))) {
            $customerRepo = new \Yatra\Repositories\CustomerRepository();
            $customerRecord = $customerRepo->find((int)$booking->customer_id);
            if ($customerRecord) {
                if (empty($customerName)) {
                    $customerName = trim(($customerRecord->first_name ?? '') . ' ' . ($customerRecord->last_name ?? '')) ?: ($customerRecord->email ?? $customerName);
                }
                if (empty($customerEmail)) {
                    $customerEmail = $customerRecord->email ?? $customerEmail;
                }
                if (empty($customerPhone)) {
                    $customerPhone = $customerRecord->phone ?? $customerPhone;
                }
                if (empty($booking->contact_first_name) && !empty($customerRecord->first_name)) {
                    $booking->contact_first_name = $customerRecord->first_name;
                }
                if (empty($booking->contact_last_name) && !empty($customerRecord->last_name)) {
                    $booking->contact_last_name = $customerRecord->last_name;
                }
                if (empty($booking->contact_country) && !empty($customerRecord->country)) {
                    $booking->contact_country = $customerRecord->country;
                }
            }
        }

        return [
            'id' => (int) $booking->id,
            'reference' => $booking->reference,
            // UI expects booking_number and booking_status fields
            'booking_number' => $booking->reference,
            'booking_status' => $booking->status,
            'trip_id' => (int) $booking->trip_id,
            'trip_title' => $booking->trip_title ?? '',
            'trip_slug' => $booking->trip_slug ?? '',
            'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
            'user_id' => $booking->user_id ? (int) $booking->user_id : null,
            'customer_name' => $customerName,
            'customer_email' => $customerEmail,
            'customer_phone' => $customerPhone,
            'contact' => [
                'first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
                'last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
                'email' => $customerEmail,
                'phone' => $customerPhone,
                'country' => $booking->contact_country,
            ],
            'contact_first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
            'contact_last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
            'contact_email' => $customerEmail,
            'contact_phone' => $customerPhone,
            'contact_country' => $booking->contact_country ?? null,
            'travel_date' => $booking->travel_date,
            'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
            'end_date' => $booking->end_date ?? null,
            // travelers_count stored; also fallback to total_travelers/travelers if present
            'travelers_count' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
            'travelers' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
            'total_amount' => (float) $booking->total_amount,
            'amount_paid' => (float) $booking->amount_paid,
            'amount_due' => (float) $booking->amount_due,
            'discount_amount' => (float) ($booking->discount_amount ?? 0),
            'discount_code' => $booking->discount_code ?? null,
            'currency' => $booking->currency,
            'tax_amount' => (float) ($booking->tax_amount ?? 0),
            'tax_rate' => (float) ($booking->tax_rate ?? 0),
            'tax_inclusive' => (int) ($booking->tax_inclusive ?? 0),
            'tax_details' => $booking->tax_details ?? null,
            'tax_breakdown' => $booking->tax_details ? json_decode($booking->tax_details, true) : [],
            'subtotal' => (float) ($booking->subtotal ?? $booking->total_amount ?? 0),
            'taxable_amount' => (float) (($booking->subtotal ?? $booking->total_amount ?? 0) + ($booking->itinerary_costs_total ?? 0)),
            'itinerary_costs' => $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [],
            'itinerary_costs_total' => (float) ($booking->itinerary_costs_total ?? 0),
            'status' => $booking->status,
            'payment_status' => $booking->payment_status,
            // Some UIs expect payment_method; map from payment_gateway
            'payment_gateway' => $booking->payment_gateway,
            'payment_method' => $booking->payment_gateway,
            // booking_date is used in admin table; map to created_at
            'booking_date' => $booking->created_at,
            'created_at' => $booking->created_at,
            'updated_at' => $booking->updated_at,
        ];
    }

    /**
     * Format booking with all details for single view
     * 
     * @param object $booking Raw booking data
     * @return array
     */
    private function formatBookingWithDetails(object $booking): array
    {
        $formatted = $this->formatBooking($booking);

        // Add customer name for easier access
        $formatted['customer_name'] = trim(
            ($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? '')
        ) ?: null;
        $formatted['customer_email'] = $booking->contact_email ?? null;
        $formatted['customer_phone'] = $booking->contact_phone ?? null;
        
        // Also add contact fields at root level for backward compatibility
        $formatted['contact_first_name'] = $booking->contact_first_name ?? null;
        $formatted['contact_last_name'] = $booking->contact_last_name ?? null;
        $formatted['contact_email'] = $booking->contact_email ?? null;
        $formatted['contact_phone'] = $booking->contact_phone ?? null;
        $formatted['contact_country'] = $booking->contact_country ?? null;

        // Add full contact data
        $formatted['contact_data'] = $booking->contact_data ? json_decode($booking->contact_data, true) : null;

        // Add emergency contact: handle JSON, serialized, or array
        $emergency = $booking->emergency_contact ?? null;
        if (is_string($emergency)) {
            $decoded = json_decode($emergency, true);
            if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
                $emergency = $decoded;
            } else {
                $maybe = maybe_unserialize($emergency);
                $emergency = is_array($maybe) ? $maybe : null;
            }
        } elseif (!is_array($emergency)) {
            $emergency = null;
        }
        $formatted['emergency_contact'] = $emergency;

        // Add travelers
        $formatted['travelers'] = $this->getBookingTravelers((int) $booking->id);

        // Add payments
        $formatted['payments'] = $this->getBookingPayments((int) $booking->id);

        // Add tax breakdown
        $formatted['tax_breakdown'] = BookingTaxService::getBookingTaxBreakdown((array) $formatted);
        $formatted['tax_display'] = BookingTaxService::formatBookingTaxDisplay((array) $formatted);
        
        // Add itinerary costs
        $formatted['itinerary_costs'] = $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [];
        $formatted['itinerary_costs_total'] = (float) ($booking->itinerary_costs_total ?? 0);

        // Add additional fields
        $formatted['special_requests'] = $booking->special_requests;
        $formatted['internal_notes'] = $booking->internal_notes;
        $formatted['payment_transaction_id'] = $booking->payment_transaction_id;
        $formatted['cancelled_at'] = $booking->cancelled_at;
        $formatted['cancellation_reason'] = $booking->cancellation_reason;
        $formatted['confirmed_at'] = $booking->confirmed_at;
        $formatted['completed_at'] = $booking->completed_at;

        /**
         * Filter: Add additional services to booking details
         * Allows premium modules to include services data in booking response
         * 
         * @param array $services Empty array by default
         * @param int $booking_id The booking ID
         * @since 3.0.0
         */
        $formatted['additional_services'] = apply_filters('yatra_booking_get_services', [], (int) $booking->id);

        $formatted = apply_filters('yatra_booking_details', $formatted, (int) $booking->id);

        return $formatted;
    }

    /**
     * Save travelers for a booking
     * 
     * @param int   $bookingId Booking ID
     * @param array $travelers Travelers data
     */
    private function saveTravelers(int $bookingId, array $travelers): void
    {
        // Re-index defensively so traveller_index / is_lead are positional and
        // contiguous regardless of the incoming keys.
        $index = 0;
        foreach ($travelers as $travelerData) {
            if (!is_array($travelerData)) {
                continue;
            }
            $isLead = $index === 0;
            // Accept both shapes: a nested { fields: {...} } (repository format)
            // or a flat field map (admin BookingForm). Drop non-field meta keys.
            $fields = isset($travelerData['fields']) && is_array($travelerData['fields'])
                ? $travelerData['fields']
                : $travelerData;
            unset($fields['is_lead'], $fields['traveller_index'], $fields['id'], $fields['booking_id']);
            // create() is the real repository method (createTraveller() never existed);
            // it inserts the traveller row and writes every field to the meta table —
            // the same method the checkout flow uses.
            $this->travellerRepository->create($bookingId, $index, $isLead, $fields);
            $index++;
        }
    }

    /**
     * Transactional "new booking" email (settings / Pro templates). Used by checkout when the session
     * defers email until after payment redirect or sends the rich HTML confirmation at the end.
     */
    public function sendNewBookingTransactionalConfirmation(int $bookingId): void
    {
        $this->sendBookingConfirmationEmail($bookingId);
    }

    /**
     * Send booking confirmation email
     * 
     * @param int $bookingId Booking ID
     */
    private function sendBookingConfirmationEmail(int $bookingId): void
    {
        $booking = $this->bookingRepository->findWithTrip($bookingId);

        if (!$booking || empty($booking->contact_email)) {
            return;
        }

        $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
        $vars['intro_paragraph'] = __('Thank you for your booking! Here are your details:', 'yatra');
        $vars['transactional_context'] = 'booking_created';

        TransactionalEmailTemplateService::sendIfEnabled(
            TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
            $booking->contact_email,
            $vars
        );
    }

    /**
     * Send status change notification
     * 
     * @param int    $bookingId Booking ID
     * @param string $oldStatus Previous status
     * @param string $newStatus New status
     */
    private function sendStatusChangeNotification(int $bookingId, string $oldStatus, string $newStatus): void
    {
        // Only send for certain status changes
        $notifyStatuses = ['confirmed', 'cancelled', 'completed'];

        if (!in_array($newStatus, $notifyStatuses, true)) {
            return;
        }

        $booking = $this->bookingRepository->findWithTrip($bookingId);

        if (!$booking || empty($booking->contact_email)) {
            return;
        }

        if ($newStatus === 'cancelled') {
            $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
            $vars['cancellation_reason'] = (string) ($booking->cancellation_reason ?? '');
            TransactionalEmailTemplateService::sendIfEnabled(
                TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
                $booking->contact_email,
                $vars
            );

            return;
        }

        if ($newStatus === 'confirmed') {
            $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
            $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra');
            $vars['transactional_context'] = 'status_confirmed';
            TransactionalEmailTemplateService::sendIfEnabled(
                TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
                $booking->contact_email,
                $vars
            );

            return;
        }

        if ($newStatus === 'completed') {
            $handled = apply_filters('yatra_send_booking_status_email_html', null, $bookingId, $oldStatus, $newStatus, $booking);
            if ($handled !== null) {
                ReviewReminderService::scheduleReminder($bookingId);

                return;
            }

            $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
            $vars['completion_date'] = date_i18n(get_option('date_format'));
            TransactionalEmailTemplateService::sendIfEnabled(
                TransactionalEmailTemplateService::TYPE_BOOKING_COMPLETED,
                $booking->contact_email,
                $vars
            );

            ReviewReminderService::scheduleReminder($bookingId);

            return;
        }
    }

    /**
     * Get all travelers with pagination
     * 
     * @param array $filters Filters
     * @return array
     */
    public function getTravelers(array $filters = []): array
    {
        return $this->travellerRepository->paginate($filters);
    }

    /**
     * Perform bulk actions on travelers
     *
     * Currently supports only delete.
     *
     * @param int[]  $ids    Traveler IDs
     * @param string $action Action key (e.g. 'delete')
     * @return array {success: bool, message: string}
     */
    public function bulkTravelers(array $ids, string $action): array
    {
        $action = trim($action);

        if ($action !== 'delete') {
            return [
                'success' => false,
                'message' => __('Invalid traveler bulk action.', 'yatra'),
            ];
        }

        return $this->travellerRepository->bulkDelete($ids);
    }

    /**
     * Send booking email
     * 
     * @param int    $bookingId Booking ID
     * @param string $emailType Email type (confirmation, reminder, etc.)
     * @return array {success: bool, message: string}
     */
    public function sendEmail(int $bookingId, string $emailType = 'confirmation'): array
    {
        $booking = $this->bookingRepository->findWithTrip($bookingId);

        if (!$booking) {
            return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
        }

        if (empty($booking->contact_email)) {
            return ['success' => false, 'message' => __('No email address found.', 'yatra')];
        }

        switch ($emailType) {
            case 'confirmation':
                $this->sendBookingConfirmationEmail($bookingId);
                break;

            case 'reminder':
                $this->sendBookingReminderEmail($booking);
                break;

            default:
                return ['success' => false, 'message' => __('Unknown email type.', 'yatra')];
        }

        return [
            'success' => true,
            'message' => __('Email sent successfully.', 'yatra'),
        ];
    }

    /**
     * Send booking reminder email
     * 
     * @param object $booking Booking data
     */
    private function sendBookingReminderEmail(object $booking): void
    {
        $daysUntilTrip = (int) ((strtotime((string) $booking->travel_date) - time()) / 86400);
        $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
        $vars['days_until_trip'] = (string) max(0, $daysUntilTrip);
        $vars['reminder_days'] = (string) SettingsService::getInt('booking_reminder_days', 3);

        $checklist = '<p><strong>' . esc_html__('Preparation checklist', 'yatra') . '</strong></p><ul>'
            . '<li>' . esc_html__('Valid government-issued ID', 'yatra') . '</li>'
            . '<li>' . esc_html__('Travel insurance', 'yatra') . '</li>'
            . '<li>' . esc_html__('Emergency contacts', 'yatra') . '</li>'
            . '</ul>';
        $vars['reminder_extra_html'] = $checklist;

        $sent = TransactionalEmailTemplateService::sendIfEnabled(
            TransactionalEmailTemplateService::TYPE_BOOKING_REMINDER,
            $booking->contact_email,
            $vars
        );

        if ($sent) {
            $this->bookingRepository->update((int) $booking->id, [
                'reminder_sent' => 1,
                'reminder_sent_at' => current_time('mysql'),
            ]);
        }
    }
}


```
