PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.9
Yatra – Travel Booking & Tour Operator Software v3.0.2.9
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Services / BookingService.php

BookingService.php in Yatra – Travel Booking & Tour Operator Software 3.0.2.9, at app/Services/BookingService.php

1,093 lines 41.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 use Yatra\Repositories\BookingRepository;
8 use Yatra\Repositories\PaymentRepository;
9 use Yatra\Repositories\TravellerRepository;
10 use Yatra\Repositories\CustomerRepository;
11 use Yatra\Repositories\TripRepository;
12 use Yatra\Repositories\DepartureRepository;
13 use Yatra\Repositories\BookingDepartureRepository;
14 use Yatra\Validators\BookingValidator;
15 use Yatra\Utils\Logger;
16 use Yatra\Services\BookingTaxService;
17
18 /**
19 * Booking Service
20 *
21 * Contains business logic for bookings.
22 * Uses repositories for data access.
23 *
24 * Note: Does not extend BaseService as it uses multiple repositories
25 * and has specialized booking-related methods.
26 *
27 * @package Yatra\Services
28 */
29 class BookingService
30 {
31 private BookingRepository $bookingRepository;
32 private PaymentRepository $paymentRepository;
33 private TravellerRepository $travellerRepository;
34 private CustomerRepository $customerRepository;
35 private TripRepository $tripRepository;
36 private DepartureService $departureService;
37
38 public function __construct()
39 {
40 $this->bookingRepository = new BookingRepository();
41 $this->paymentRepository = new PaymentRepository();
42 $this->travellerRepository = new TravellerRepository();
43 $this->customerRepository = new CustomerRepository();
44 $this->tripRepository = new TripRepository();
45 $this->departureService = new DepartureService(
46 new DepartureRepository(),
47 new BookingDepartureRepository(),
48 $this->bookingRepository,
49 $this->tripRepository
50 );
51 }
52
53 /**
54 * Get paginated bookings
55 *
56 * @param array $filters Filters (page, per_page, status, search, etc.)
57 * @return array
58 */
59 public function getBookings(array $filters = []): array
60 {
61 $result = $this->bookingRepository->paginate($filters);
62
63 // Format each booking
64 $result['data'] = array_map([$this, 'formatBooking'], $result['data']);
65
66 return $result;
67 }
68
69 /**
70 * Get single booking with all related data
71 *
72 * @param int $id Booking ID
73 * @return array|null
74 */
75 public function getBooking(int $id): ?array
76 {
77 $booking = $this->bookingRepository->findWithTrip($id);
78
79 if (!$booking) {
80 return null;
81 }
82
83 $formatted = $this->formatBookingWithDetails($booking);
84
85 return $formatted;
86 }
87
88 /**
89 * Get booking by reference code
90 *
91 * @param string $reference Booking reference
92 * @return array|null
93 */
94 public function getBookingByReference(string $reference): ?array
95 {
96 $booking = $this->bookingRepository->findByReference($reference);
97
98 if (!$booking) {
99 return null;
100 }
101
102 return $this->formatBookingWithDetails($booking);
103 }
104
105 /**
106 * Validate booking business rules
107 */
108 private function validateBookingBusinessRules(array $data): array
109 {
110 // Check minimum travelers count
111 $travelersCount = (int) ($data['travelers_count'] ?? 0);
112 if ($travelersCount <= 0) {
113 return [
114 'success' => false,
115 'message' => __('At least one traveler is required for booking.', 'yatra')
116 ];
117 }
118
119 // Check trip capacity if specified
120 if (!empty($data['trip_id'])) {
121 $trip = $this->tripRepository->find((int) $data['trip_id']);
122 if ($trip && !empty($trip->max_travelers)) {
123 $maxCapacity = (int) $trip->max_travelers;
124 if ($travelersCount > $maxCapacity) {
125 return [
126 'success' => false,
127 'message' => sprintf(
128 __('Maximum %d travelers allowed for this trip.', 'yatra'),
129 $maxCapacity
130 )
131 ];
132 }
133 }
134 }
135
136 // Check booking date is in the future
137 if (!empty($data['start_date'])) {
138 $startDate = strtotime($data['start_date']);
139 $today = strtotime('today');
140
141 if ($startDate < $today) {
142 return [
143 'success' => false,
144 'message' => __('Booking date must be in the future.', 'yatra')
145 ];
146 }
147 }
148
149 // Check total amount is positive
150 $totalAmount = (float) ($data['total_amount'] ?? 0);
151 if ($totalAmount <= 0) {
152 return [
153 'success' => false,
154 'message' => __('Total amount must be greater than zero.', 'yatra')
155 ];
156 }
157
158 // Check payment amount doesn't exceed total
159 $amountPaid = (float) ($data['amount_paid'] ?? 0);
160 if ($amountPaid > $totalAmount) {
161 return [
162 'success' => false,
163 'message' => __('Payment amount cannot exceed total booking amount.', 'yatra')
164 ];
165 }
166
167 // Validate tax configuration
168 $taxValidation = BookingTaxService::validateBookingTax($data);
169 if (!$taxValidation['valid']) {
170 return [
171 'success' => false,
172 'message' => __('Tax configuration error.', 'yatra'),
173 'errors' => $taxValidation['errors']
174 ];
175 }
176
177 return ['success' => true];
178 }
179
180 /**
181 * Create a new booking with comprehensive validation and business rules
182 *
183 * @param array $data Booking data
184 * @return array {success: bool, booking_id?: int, reference?: string, message?: string}
185 */
186 public function createBooking(array $data): array
187 {
188 $startTime = microtime(true);
189
190 // Checkout defers the rich confirmation to BookingSessionController (avoids duplicate customer emails).
191 // Not persisted; stripped by BookingValidator::sanitize().
192 $skipInitialCustomerConfirmation = !empty($data['skip_initial_customer_confirmation']);
193
194 try {
195 Logger::info("Booking creation started", [
196 'data_keys' => array_keys($data),
197 'trip_id' => $data['trip_id'] ?? null,
198 'has_itinerary_costs' => isset($data['itinerary_costs']),
199 'has_itinerary_costs_total' => isset($data['itinerary_costs_total'])
200 ]);
201
202 // Comprehensive validation using BookingValidator
203 try {
204 BookingValidator::validateCreate($data);
205 } catch (\Yatra\Exceptions\ValidationException $e) {
206 Logger::warning('Booking validation failed', [
207 'trip_id' => $data['trip_id'] ?? null,
208 'errors' => $e->getErrors(),
209 ]);
210 return [
211 'success' => false,
212 'message' => $e->getMessage() ?: __('Booking validation failed.', 'yatra'),
213 'errors' => $e->getErrors(),
214 ];
215 }
216
217 $data = BookingValidator::sanitize($data);
218
219 Logger::info("After BookingValidator::sanitize", [
220 'data_keys' => array_keys($data),
221 'has_itinerary_costs' => isset($data['itinerary_costs']),
222 'has_itinerary_costs_total' => isset($data['itinerary_costs_total'])
223 ]);
224
225 // Business rule validations
226 $validationResult = $this->validateBookingBusinessRules($data);
227 if (!$validationResult['success']) {
228 Logger::warning("Booking business rule validation failed", [
229 'trip_id' => $data['trip_id'],
230 'reason' => $validationResult['message']
231 ]);
232 return $validationResult;
233 }
234
235 // Validate trip exists and is available
236 $trip = $this->tripRepository->find((int) $data['trip_id']);
237 if (!$trip) {
238 Logger::error("Trip not found for booking", ['trip_id' => $data['trip_id']]);
239 return ['success' => false, 'message' => __('Trip not found.', 'yatra')];
240 }
241
242 if ($trip->status !== 'publish') {
243 Logger::warning("Trip not available for booking", [
244 'trip_id' => $data['trip_id'],
245 'status' => $trip->status
246 ]);
247 return ['success' => false, 'message' => __('Trip is not available for booking.', 'yatra')];
248 }
249
250 // Calculate start_date and end_date if travel_date is provided
251 if (!empty($data['travel_date']) && empty($data['start_date'])) {
252 $data['start_date'] = $data['travel_date'];
253 }
254
255 if (!empty($data['start_date']) && empty($data['end_date'])) {
256 $data['end_date'] = $this->calculateEndDate($data['start_date'], (int) $data['trip_id']);
257 }
258
259 // Generate unique reference
260 $data['reference'] = $this->bookingRepository->generateReference();
261
262 // Find or create customer
263 if (!empty($data['contact_email'])) {
264 $customerId = $this->customerRepository->findOrCreate([
265 'email' => $data['contact_email'],
266 'first_name' => $data['contact_first_name'] ?? '',
267 'last_name' => $data['contact_last_name'] ?? '',
268 'phone' => $data['contact_phone'] ?? '',
269 'country' => $data['contact_country'] ?? '',
270 'user_id' => $data['user_id'] ?? null,
271 ]);
272 $data['customer_id'] = $customerId;
273 }
274
275 // Apply tax calculation to booking data
276 $data = BookingTaxService::applyTaxToBooking($data);
277
278 // Calculate amounts (recalculated after tax)
279 $data['amount_due'] = (float) ($data['total_amount'] ?? 0) - (float) ($data['amount_paid'] ?? 0);
280
281 // Create booking
282 $bookingId = $this->bookingRepository->create($data);
283
284 if (!$bookingId) {
285 Logger::error("Failed to create booking in database", ['data' => $data]);
286 return ['success' => false, 'message' => __('Failed to create booking.', 'yatra')];
287 }
288
289 // Waitlist bookings do not consume departure capacity until promoted.
290 $isWaitlist = isset($data['status']) && $data['status'] === 'waitlist';
291
292 // Link booking to departure if start_date is provided
293 if (!$isWaitlist && !empty($data['start_date']) && !empty($data['end_date'])) {
294 try {
295 $trip = $this->tripRepository->find((int) $data['trip_id']);
296 // Get max capacity from trip's max_travelers, or use default
297 $maxCapacity = null;
298 if ($trip && !empty($trip->max_travelers)) {
299 $maxCapacity = (int) $trip->max_travelers;
300 }
301 $travelersCount = (int) ($data['travelers_count'] ?? 0);
302
303 $departureTime = null;
304 if (!empty($data['departure_time']) && is_string($data['departure_time'])) {
305 $departureTime = trim($data['departure_time']);
306 if ($departureTime === '') {
307 $departureTime = null;
308 }
309 }
310
311 // Find or create departure
312 $departure = $this->departureService->findOrCreateForBooking(
313 (int) $data['trip_id'],
314 $data['start_date'],
315 $data['end_date'],
316 $travelersCount,
317 $maxCapacity,
318 $departureTime
319 );
320
321 // Link booking to departure
322 $this->departureService->linkBookingToDeparture($bookingId, $departure->id);
323
324 // Increment booked count
325 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
326
327 Logger::info("Booking linked to departure", [
328 'booking_id' => $bookingId,
329 'departure_id' => $departure->id
330 ]);
331 } catch (\Exception $e) {
332 // Log error but don't fail the booking
333 Logger::warning("Failed to link booking to departure", [
334 'booking_id' => $bookingId,
335 'error' => $e->getMessage()
336 ]);
337 }
338 }
339
340 // Save travelers
341 if (!empty($data['travelers']) && is_array($data['travelers'])) {
342 $this->saveTravelers($bookingId, $data['travelers']);
343 }
344
345 // Customer confirmation: skip when checkout will send the session email (offline / zero due).
346 if (!$skipInitialCustomerConfirmation) {
347 $this->sendBookingConfirmationEmail($bookingId);
348 }
349
350 $executionTime = microtime(true) - $startTime;
351 Logger::info("Booking created successfully", [
352 'booking_id' => $bookingId,
353 'reference' => $data['reference'],
354 'execution_time' => $executionTime
355 ]);
356
357 $booking = $this->bookingRepository->find((int) $bookingId);
358 if (!is_object($booking)) {
359 $booking = (object) [];
360 }
361
362 do_action(\Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED, (int) $bookingId, $booking);
363
364 return [
365 'success' => true,
366 'booking_id' => $bookingId,
367 'reference' => $data['reference'],
368 'message' => __('Booking created successfully.', 'yatra'),
369 ];
370
371 } catch (\Exception $e) {
372 $executionTime = microtime(true) - $startTime;
373 Logger::error("Booking creation failed", [
374 'trip_id' => $data['trip_id'] ?? null,
375 'execution_time' => $executionTime,
376 'error' => $e->getMessage()
377 ]);
378
379 return [
380 'success' => false,
381 'message' => $e->getMessage()
382 ];
383 }
384 }
385
386 /**
387 * Update a booking
388 *
389 * @param int $id Booking ID
390 * @param array $data Booking data
391 * @return array {success: bool, message: string}
392 */
393 public function updateBooking(int $id, array $data): array
394 {
395 $booking = $this->bookingRepository->find($id);
396
397 if (!$booking) {
398 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
399 }
400
401 // Check if date is being changed
402 $oldStartDate = $booking->start_date ?? $booking->travel_date ?? null;
403 $newStartDate = $data['start_date'] ?? $data['travel_date'] ?? null;
404 $dateChanged = false;
405
406 if ($newStartDate && $oldStartDate && $newStartDate !== $oldStartDate) {
407 $dateChanged = true;
408 }
409
410 // Calculate end_date if start_date is provided
411 if (!empty($data['start_date']) && empty($data['end_date'])) {
412 $data['end_date'] = $this->calculateEndDate($data['start_date'], (int) $booking->trip_id);
413 } elseif (!empty($data['travel_date']) && empty($data['start_date']) && empty($data['end_date'])) {
414 $data['start_date'] = $data['travel_date'];
415 $data['end_date'] = $this->calculateEndDate($data['travel_date'], (int) $booking->trip_id);
416 }
417
418 $oldStatus = (string) ($booking->status ?? '');
419
420 // Update booking
421 $updated = $this->bookingRepository->update($id, $data);
422
423 if (!$updated) {
424 return ['success' => false, 'message' => __('Failed to update booking.', 'yatra')];
425 }
426
427 $newStatus = isset($data['status']) ? (string) $data['status'] : null;
428 if ($newStatus !== null && $oldStatus === 'waitlist' && $newStatus !== 'waitlist') {
429 WaitlistService::releaseWaitlistHolding($booking);
430 }
431
432 // Handle departure date change if date was changed
433 if ($dateChanged && !empty($data['start_date']) && !empty($data['end_date'])) {
434 try {
435 $this->departureService->handleBookingDateChange(
436 $id,
437 $data['start_date'],
438 $data['end_date']
439 );
440 } catch (\Exception $e) {
441 // Log error but don't fail the update
442 }
443 }
444
445 // Update travelers if provided
446 if (isset($data['travelers']) && is_array($data['travelers'])) {
447 // Delete existing travelers
448 $this->travellerRepository->deleteByBookingId($id);
449 // Save new travelers
450 $this->saveTravelers($id, $data['travelers']);
451 }
452
453 return [
454 'success' => true,
455 'message' => __('Booking updated successfully.', 'yatra'),
456 ];
457 }
458
459 /**
460 * Calculate end date from start date and trip duration
461 *
462 * @param string $startDate Start date (YYYY-MM-DD)
463 * @param int $tripId Trip ID
464 * @return string End date (YYYY-MM-DD)
465 */
466 private function calculateEndDate(string $startDate, int $tripId): string
467 {
468 return $this->bookingRepository->calculateEndDate($startDate, $tripId);
469 }
470
471 /**
472 * Update booking status
473 *
474 * @param int $id Booking ID
475 * @param string $status New status
476 * @return array {success: bool, message: string}
477 */
478 public function updateStatus(int $id, string $status): array
479 {
480 $validStatuses = ['pending', 'confirmed', 'processing', 'completed', 'cancelled', 'refunded', 'failed', 'on_hold'];
481
482 if (!in_array($status, $validStatuses, true)) {
483 return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
484 }
485
486 $booking = $this->bookingRepository->find($id);
487
488 if (!$booking) {
489 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
490 }
491
492 $oldStatus = $booking->status;
493 $updated = $this->bookingRepository->updateStatus($id, $status);
494
495 if (!$updated) {
496 return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
497 }
498
499 if ($oldStatus === 'waitlist' && $status !== 'waitlist') {
500 WaitlistService::releaseWaitlistHolding($booking);
501 }
502
503 // ========================================
504 // HANDLE DEPARTURE BOOKED_COUNT UPDATE
505 // ========================================
506 // If booking is cancelled or refunded, unlink from departure and decrement booked_count
507 // If booking status changes from cancelled/refunded to active, link and increment booked_count
508 try {
509 $departure = $this->departureService->getDepartureForBooking($id);
510 $travelersCount = (int) ($booking->travelers_count ?? 0);
511
512 if ($departure) {
513 // If booking is being cancelled or refunded
514 if (in_array($status, ['cancelled', 'refunded'], true) &&
515 !in_array($oldStatus, ['cancelled', 'refunded'], true)) {
516 // Unlink booking from departure (this will handle cancellation if no bookings remain)
517 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
518 }
519 // If booking status changes from cancelled/refunded back to active
520 elseif (in_array($oldStatus, ['cancelled', 'refunded'], true) &&
521 !in_array($status, ['cancelled', 'refunded'], true)) {
522 // Ensure booking is linked and increment booked count
523 $this->departureService->linkBookingToDeparture($id, $departure->id);
524 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
525 }
526 } elseif (!empty($booking->start_date) || !empty($booking->travel_date)) {
527 // Booking doesn't have a departure yet, but has a date - create and link
528 $startDate = $booking->start_date ?? $booking->travel_date;
529 $endDate = $booking->end_date ?? $this->calculateEndDate($startDate, (int) $booking->trip_id);
530
531 $trip = $this->tripRepository->find((int) $booking->trip_id);
532 $maxCapacity = $trip ? ($trip->max_capacity ?? 9999) : 9999;
533
534 $departure = $this->departureService->findOrCreateForBooking(
535 (int) $booking->trip_id,
536 $startDate,
537 $endDate,
538 $travelersCount,
539 $maxCapacity
540 );
541
542 $this->departureService->linkBookingToDeparture($id, $departure->id);
543 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
544 }
545 } catch (\Exception $e) {
546 // Log error but don't fail the status update
547 }
548
549 // Send status change notification
550 $this->sendStatusChangeNotification($id, $oldStatus, $status);
551
552 /**
553 * Action: Booking status changed
554 * Fires when booking status changes
555 *
556 * @param int $id The booking ID
557 * @param string $oldStatus Previous status
558 * @param string $status New status
559 * @since 3.0.0
560 */
561 do_action('yatra_booking_status_changed', $id, $oldStatus, $status);
562
563 return [
564 'success' => true,
565 'message' => sprintf(__('Booking status updated to %s.', 'yatra'), $status),
566 ];
567 }
568
569 /**
570 * Delete a booking
571 *
572 * @param int $id Booking ID
573 * @return array {success: bool, message: string}
574 */
575 public function deleteBooking(int $id): array
576 {
577 $booking = $this->bookingRepository->find($id);
578
579 if (!$booking) {
580 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
581 }
582
583 if (($booking->status ?? '') === 'waitlist') {
584 WaitlistService::releaseWaitlistHolding($booking);
585 }
586
587 try {
588 $departure = $this->departureService->getDepartureForBooking($id);
589 if ($departure) {
590 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
591 }
592 } catch (\Throwable $e) {
593 // Continue with delete
594 }
595
596 // Delete related travelers
597 $this->travellerRepository->deleteByBookingId($id);
598
599 // Delete booking
600 $deleted = $this->bookingRepository->delete($id);
601
602 if (!$deleted) {
603 return ['success' => false, 'message' => __('Failed to delete booking.', 'yatra')];
604 }
605
606 if (!is_object($booking)) {
607 $booking = (object) [];
608 }
609
610 do_action('yatra_booking_deleted', (int) $id, $booking);
611
612 return [
613 'success' => true,
614 'message' => __('Booking deleted successfully.', 'yatra'),
615 ];
616 }
617
618 /**
619 * Get booking statistics
620 *
621 * @return array
622 */
623 public function getStats(): array
624 {
625 return $this->bookingRepository->getStats();
626 }
627
628 /**
629 * Get booking payments
630 *
631 * @param int $bookingId Booking ID
632 * @return array
633 */
634 public function getBookingPayments(int $bookingId): array
635 {
636 return $this->paymentRepository->findByBookingId($bookingId);
637 }
638
639 /**
640 * Get booking travelers
641 *
642 * @param int $bookingId Booking ID
643 * @return array
644 */
645 public function getBookingTravelers(int $bookingId): array
646 {
647 return $this->travellerRepository->getByBookingId($bookingId);
648 }
649
650 /**
651 * Format booking for API response
652 *
653 * @param object $booking Raw booking data
654 * @return array
655 */
656 private function formatBooking(object $booking): array
657 {
658 // Build customer name from contact fields
659 $customerName = trim(
660 ($booking->customer_first_name ?? $booking->contact_first_name ?? '') . ' ' . ($booking->customer_last_name ?? $booking->contact_last_name ?? '')
661 ) ?: ($booking->customer_name ?? $booking->customer_email ?? $booking->contact_email ?? null);
662
663 $customerEmail = $booking->customer_email ?? $booking->contact_email ?? null;
664 $customerPhone = $booking->contact_phone ?? null;
665
666 // Fallback: fetch customer record if customer_id is set and info missing
667 if (($booking->customer_id ?? 0) && (empty($customerName) || empty($customerEmail) || empty($customerPhone))) {
668 $customerRepo = new \Yatra\Repositories\CustomerRepository();
669 $customerRecord = $customerRepo->find((int)$booking->customer_id);
670 if ($customerRecord) {
671 if (empty($customerName)) {
672 $customerName = trim(($customerRecord->first_name ?? '') . ' ' . ($customerRecord->last_name ?? '')) ?: ($customerRecord->email ?? $customerName);
673 }
674 if (empty($customerEmail)) {
675 $customerEmail = $customerRecord->email ?? $customerEmail;
676 }
677 if (empty($customerPhone)) {
678 $customerPhone = $customerRecord->phone ?? $customerPhone;
679 }
680 if (empty($booking->contact_first_name) && !empty($customerRecord->first_name)) {
681 $booking->contact_first_name = $customerRecord->first_name;
682 }
683 if (empty($booking->contact_last_name) && !empty($customerRecord->last_name)) {
684 $booking->contact_last_name = $customerRecord->last_name;
685 }
686 if (empty($booking->contact_country) && !empty($customerRecord->country)) {
687 $booking->contact_country = $customerRecord->country;
688 }
689 }
690 }
691
692 return [
693 'id' => (int) $booking->id,
694 'reference' => $booking->reference,
695 // UI expects booking_number and booking_status fields
696 'booking_number' => $booking->reference,
697 'booking_status' => $booking->status,
698 'trip_id' => (int) $booking->trip_id,
699 'trip_title' => $booking->trip_title ?? '',
700 'trip_slug' => $booking->trip_slug ?? '',
701 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
702 'user_id' => $booking->user_id ? (int) $booking->user_id : null,
703 'customer_name' => $customerName,
704 'customer_email' => $customerEmail,
705 'customer_phone' => $customerPhone,
706 'contact' => [
707 'first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
708 'last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
709 'email' => $customerEmail,
710 'phone' => $customerPhone,
711 'country' => $booking->contact_country,
712 ],
713 'contact_first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
714 'contact_last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
715 'contact_email' => $customerEmail,
716 'contact_phone' => $customerPhone,
717 'contact_country' => $booking->contact_country ?? null,
718 'travel_date' => $booking->travel_date,
719 // travelers_count stored; also fallback to total_travelers/travelers if present
720 'travelers_count' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
721 'travelers' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
722 'total_amount' => (float) $booking->total_amount,
723 'amount_paid' => (float) $booking->amount_paid,
724 'amount_due' => (float) $booking->amount_due,
725 'discount_amount' => (float) ($booking->discount_amount ?? 0),
726 'discount_code' => $booking->discount_code ?? null,
727 'currency' => $booking->currency,
728 'tax_amount' => (float) ($booking->tax_amount ?? 0),
729 'tax_rate' => (float) ($booking->tax_rate ?? 0),
730 'tax_inclusive' => (int) ($booking->tax_inclusive ?? 0),
731 'tax_details' => $booking->tax_details ?? null,
732 'tax_breakdown' => $booking->tax_details ? json_decode($booking->tax_details, true) : [],
733 'subtotal' => (float) ($booking->subtotal ?? $booking->total_amount ?? 0),
734 'taxable_amount' => (float) (($booking->subtotal ?? $booking->total_amount ?? 0) + ($booking->itinerary_costs_total ?? 0)),
735 'itinerary_costs' => $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [],
736 'itinerary_costs_total' => (float) ($booking->itinerary_costs_total ?? 0),
737 'status' => $booking->status,
738 'payment_status' => $booking->payment_status,
739 // Some UIs expect payment_method; map from payment_gateway
740 'payment_gateway' => $booking->payment_gateway,
741 'payment_method' => $booking->payment_gateway,
742 // booking_date is used in admin table; map to created_at
743 'booking_date' => $booking->created_at,
744 'created_at' => $booking->created_at,
745 'updated_at' => $booking->updated_at,
746 ];
747 }
748
749 /**
750 * Format booking with all details for single view
751 *
752 * @param object $booking Raw booking data
753 * @return array
754 */
755 private function formatBookingWithDetails(object $booking): array
756 {
757 $formatted = $this->formatBooking($booking);
758
759 // Add customer name for easier access
760 $formatted['customer_name'] = trim(
761 ($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? '')
762 ) ?: null;
763 $formatted['customer_email'] = $booking->contact_email ?? null;
764 $formatted['customer_phone'] = $booking->contact_phone ?? null;
765
766 // Also add contact fields at root level for backward compatibility
767 $formatted['contact_first_name'] = $booking->contact_first_name ?? null;
768 $formatted['contact_last_name'] = $booking->contact_last_name ?? null;
769 $formatted['contact_email'] = $booking->contact_email ?? null;
770 $formatted['contact_phone'] = $booking->contact_phone ?? null;
771 $formatted['contact_country'] = $booking->contact_country ?? null;
772
773 // Add full contact data
774 $formatted['contact_data'] = $booking->contact_data ? json_decode($booking->contact_data, true) : null;
775
776 // Add emergency contact: handle JSON, serialized, or array
777 $emergency = $booking->emergency_contact ?? null;
778 if (is_string($emergency)) {
779 $decoded = json_decode($emergency, true);
780 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
781 $emergency = $decoded;
782 } else {
783 $maybe = maybe_unserialize($emergency);
784 $emergency = is_array($maybe) ? $maybe : null;
785 }
786 } elseif (!is_array($emergency)) {
787 $emergency = null;
788 }
789 $formatted['emergency_contact'] = $emergency;
790
791 // Add travelers
792 $formatted['travelers'] = $this->getBookingTravelers((int) $booking->id);
793
794 // Add payments
795 $formatted['payments'] = $this->getBookingPayments((int) $booking->id);
796
797 // Add tax breakdown
798 $formatted['tax_breakdown'] = BookingTaxService::getBookingTaxBreakdown((array) $formatted);
799 $formatted['tax_display'] = BookingTaxService::formatBookingTaxDisplay((array) $formatted);
800
801 // Add itinerary costs
802 $formatted['itinerary_costs'] = $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [];
803 $formatted['itinerary_costs_total'] = (float) ($booking->itinerary_costs_total ?? 0);
804
805 // Add additional fields
806 $formatted['special_requests'] = $booking->special_requests;
807 $formatted['internal_notes'] = $booking->internal_notes;
808 $formatted['payment_transaction_id'] = $booking->payment_transaction_id;
809 $formatted['cancelled_at'] = $booking->cancelled_at;
810 $formatted['cancellation_reason'] = $booking->cancellation_reason;
811 $formatted['confirmed_at'] = $booking->confirmed_at;
812 $formatted['completed_at'] = $booking->completed_at;
813
814 /**
815 * Filter: Add additional services to booking details
816 * Allows premium modules to include services data in booking response
817 *
818 * @param array $services Empty array by default
819 * @param int $booking_id The booking ID
820 * @since 3.0.0
821 */
822 $formatted['additional_services'] = apply_filters('yatra_booking_get_services', [], (int) $booking->id);
823
824 $formatted = apply_filters('yatra_booking_details', $formatted, (int) $booking->id);
825
826 return $formatted;
827 }
828
829 /**
830 * Save travelers for a booking
831 *
832 * @param int $bookingId Booking ID
833 * @param array $travelers Travelers data
834 */
835 private function saveTravelers(int $bookingId, array $travelers): void
836 {
837 foreach ($travelers as $index => $travelerData) {
838 $isLead = $index === 0;
839 $this->travellerRepository->createTraveller($bookingId, $index, $isLead, $travelerData);
840 }
841 }
842
843 /**
844 * Transactional "new booking" email (settings / Pro templates). Used by checkout when the session
845 * defers email until after payment redirect or sends the rich HTML confirmation at the end.
846 */
847 public function sendNewBookingTransactionalConfirmation(int $bookingId): void
848 {
849 $this->sendBookingConfirmationEmail($bookingId);
850 }
851
852 /**
853 * Send booking confirmation email
854 *
855 * @param int $bookingId Booking ID
856 */
857 private function sendBookingConfirmationEmail(int $bookingId): void
858 {
859 $booking = $this->bookingRepository->findWithTrip($bookingId);
860
861 if (!$booking || empty($booking->contact_email)) {
862 return;
863 }
864
865 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
866 $vars['intro_paragraph'] = __('Thank you for your booking! Here are your details:', 'yatra');
867 $vars['transactional_context'] = 'booking_created';
868
869 TransactionalEmailTemplateService::sendIfEnabled(
870 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
871 $booking->contact_email,
872 $vars
873 );
874 }
875
876 /**
877 * Send status change notification
878 *
879 * @param int $bookingId Booking ID
880 * @param string $oldStatus Previous status
881 * @param string $newStatus New status
882 */
883 private function sendStatusChangeNotification(int $bookingId, string $oldStatus, string $newStatus): void
884 {
885 // Only send for certain status changes
886 $notifyStatuses = ['confirmed', 'cancelled', 'completed'];
887
888 if (!in_array($newStatus, $notifyStatuses, true)) {
889 return;
890 }
891
892 $booking = $this->bookingRepository->findWithTrip($bookingId);
893
894 if (!$booking || empty($booking->contact_email)) {
895 return;
896 }
897
898 if ($newStatus === 'cancelled') {
899 TransactionalEmailTemplateService::sendIfEnabled(
900 TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
901 $booking->contact_email,
902 TransactionalEmailTemplateService::variablesFromBooking($booking)
903 );
904
905 return;
906 }
907
908 if ($newStatus === 'confirmed') {
909 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
910 $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra');
911 $vars['transactional_context'] = 'status_confirmed';
912 TransactionalEmailTemplateService::sendIfEnabled(
913 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
914 $booking->contact_email,
915 $vars
916 );
917
918 return;
919 }
920
921 if ($newStatus === 'completed') {
922 $handled = apply_filters('yatra_send_booking_status_email_html', null, $bookingId, $oldStatus, $newStatus, $booking);
923 if ($handled !== null) {
924 return;
925 }
926 }
927
928 $subject = sprintf(
929 __('[%s] Booking Status Update - %s', 'yatra'),
930 get_bloginfo('name'),
931 $booking->reference
932 );
933 $message = $this->getStatusChangeEmailContent($booking, $newStatus);
934
935 EmailService::send(
936 $booking->contact_email,
937 $subject,
938 $message,
939 ['Content-Type: text/html; charset=UTF-8']
940 );
941 }
942
943 /**
944 * Get status change email content
945 *
946 * @param object $booking Booking data
947 * @param string $newStatus New status
948 * @return string HTML email content
949 */
950 private function getStatusChangeEmailContent(object $booking, string $newStatus): string
951 {
952 $statusMessages = [
953 'confirmed' => __('Your booking has been confirmed!', 'yatra'),
954 'cancelled' => __('Your booking has been cancelled.', 'yatra'),
955 'completed' => __('Your trip has been completed. Thank you for traveling with us!', 'yatra'),
956 ];
957
958 $message = $statusMessages[$newStatus] ?? sprintf(__('Your booking status has been updated to: %s', 'yatra'), $newStatus);
959
960 ob_start();
961 ?>
962 <!DOCTYPE html>
963 <html>
964 <head>
965 <meta charset="UTF-8">
966 </head>
967 <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
968 <h1 style="color: #2563eb;"><?php esc_html_e('Booking Update', 'yatra'); ?></h1>
969
970 <p><?php echo esc_html($message); ?></p>
971
972 <div style="background: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
973 <p><strong><?php esc_html_e('Reference:', 'yatra'); ?></strong> <?php echo esc_html($booking->reference); ?></p>
974 <p><strong><?php esc_html_e('Trip:', 'yatra'); ?></strong> <?php echo esc_html($booking->trip_title); ?></p>
975 <p><strong><?php esc_html_e('Travel Date:', 'yatra'); ?></strong> <?php echo esc_html(date_i18n(get_option('date_format'), strtotime($booking->travel_date))); ?></p>
976 </div>
977
978 <p style="margin-top: 30px; color: #666; font-size: 14px;">
979 <?php echo esc_html(get_bloginfo('name')); ?>
980 </p>
981 </body>
982 </html>
983 <?php
984 return ob_get_clean();
985 }
986
987 /**
988 * Get all travelers with pagination
989 *
990 * @param array $filters Filters
991 * @return array
992 */
993 public function getTravelers(array $filters = []): array
994 {
995 return $this->travellerRepository->paginate($filters);
996 }
997
998 /**
999 * Perform bulk actions on travelers
1000 *
1001 * Currently supports only delete.
1002 *
1003 * @param int[] $ids Traveler IDs
1004 * @param string $action Action key (e.g. 'delete')
1005 * @return array {success: bool, message: string}
1006 */
1007 public function bulkTravelers(array $ids, string $action): array
1008 {
1009 $action = trim($action);
1010
1011 if ($action !== 'delete') {
1012 return [
1013 'success' => false,
1014 'message' => __('Invalid traveler bulk action.', 'yatra'),
1015 ];
1016 }
1017
1018 return $this->travellerRepository->bulkDelete($ids);
1019 }
1020
1021 /**
1022 * Send booking email
1023 *
1024 * @param int $bookingId Booking ID
1025 * @param string $emailType Email type (confirmation, reminder, etc.)
1026 * @return array {success: bool, message: string}
1027 */
1028 public function sendEmail(int $bookingId, string $emailType = 'confirmation'): array
1029 {
1030 $booking = $this->bookingRepository->findWithTrip($bookingId);
1031
1032 if (!$booking) {
1033 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
1034 }
1035
1036 if (empty($booking->contact_email)) {
1037 return ['success' => false, 'message' => __('No email address found.', 'yatra')];
1038 }
1039
1040 switch ($emailType) {
1041 case 'confirmation':
1042 $this->sendBookingConfirmationEmail($bookingId);
1043 break;
1044
1045 case 'reminder':
1046 $this->sendBookingReminderEmail($booking);
1047 break;
1048
1049 default:
1050 return ['success' => false, 'message' => __('Unknown email type.', 'yatra')];
1051 }
1052
1053 return [
1054 'success' => true,
1055 'message' => __('Email sent successfully.', 'yatra'),
1056 ];
1057 }
1058
1059 /**
1060 * Send booking reminder email
1061 *
1062 * @param object $booking Booking data
1063 */
1064 private function sendBookingReminderEmail(object $booking): void
1065 {
1066 $daysUntilTrip = (int) ((strtotime((string) $booking->travel_date) - time()) / 86400);
1067 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1068 $vars['days_until_trip'] = (string) max(0, $daysUntilTrip);
1069 $vars['reminder_days'] = (string) SettingsService::getInt('booking_reminder_days', 3);
1070
1071 $checklist = '<p><strong>' . esc_html__('Preparation checklist', 'yatra') . '</strong></p><ul>'
1072 . '<li>' . esc_html__('Valid government-issued ID', 'yatra') . '</li>'
1073 . '<li>' . esc_html__('Travel insurance', 'yatra') . '</li>'
1074 . '<li>' . esc_html__('Emergency contacts', 'yatra') . '</li>'
1075 . '</ul>';
1076 $vars['reminder_extra_html'] = $checklist;
1077
1078 $sent = TransactionalEmailTemplateService::sendIfEnabled(
1079 TransactionalEmailTemplateService::TYPE_BOOKING_REMINDER,
1080 $booking->contact_email,
1081 $vars
1082 );
1083
1084 if ($sent) {
1085 $this->bookingRepository->update((int) $booking->id, [
1086 'reminder_sent' => 1,
1087 'reminder_sent_at' => current_time('mysql'),
1088 ]);
1089 }
1090 }
1091 }
1092
1093