PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5
Yatra – Travel Booking & Tour Operator Software v3.0.5
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.5, at app/Services/BookingService.php

1,169 lines 46.5 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 /* translators: %d: maximum number of travelers allowed. */
129 __('Maximum %d travelers allowed for this trip.', 'yatra'),
130 $maxCapacity
131 )
132 ];
133 }
134 }
135 }
136
137 // Check booking date is in the future
138 if (!empty($data['start_date'])) {
139 $startDate = strtotime($data['start_date']);
140 $today = strtotime('today');
141
142 if ($startDate < $today) {
143 return [
144 'success' => false,
145 'message' => __('Booking date must be in the future.', 'yatra')
146 ];
147 }
148 }
149
150 // Check total amount is positive
151 $totalAmount = (float) ($data['total_amount'] ?? 0);
152 if ($totalAmount <= 0) {
153 return [
154 'success' => false,
155 'message' => __('Total amount must be greater than zero.', 'yatra')
156 ];
157 }
158
159 // Check payment amount doesn't exceed total
160 $amountPaid = (float) ($data['amount_paid'] ?? 0);
161 if ($amountPaid > $totalAmount) {
162 return [
163 'success' => false,
164 'message' => __('Payment amount cannot exceed total booking amount.', 'yatra')
165 ];
166 }
167
168 // Validate tax configuration
169 $taxValidation = BookingTaxService::validateBookingTax($data);
170 if (!$taxValidation['valid']) {
171 return [
172 'success' => false,
173 'message' => __('Tax configuration error.', 'yatra'),
174 'errors' => $taxValidation['errors']
175 ];
176 }
177
178 return ['success' => true];
179 }
180
181 /**
182 * Create a new booking with comprehensive validation and business rules
183 *
184 * @param array $data Booking data
185 * @return array {success: bool, booking_id?: int, reference?: string, message?: string}
186 */
187 public function createBooking(array $data): array
188 {
189 $startTime = microtime(true);
190
191 // Checkout defers the rich confirmation to BookingSessionController (avoids duplicate customer emails).
192 // Not persisted; stripped by BookingValidator::sanitize().
193 $skipInitialCustomerConfirmation = !empty($data['skip_initial_customer_confirmation']);
194
195 try {
196 Logger::info("Booking creation started", [
197 'data_keys' => array_keys($data),
198 'trip_id' => $data['trip_id'] ?? null,
199 'has_itinerary_costs' => isset($data['itinerary_costs']),
200 'has_itinerary_costs_total' => isset($data['itinerary_costs_total'])
201 ]);
202
203 // Comprehensive validation using BookingValidator
204 try {
205 BookingValidator::validateCreate($data);
206 } catch (\Yatra\Exceptions\ValidationException $e) {
207 Logger::warning('Booking validation failed', [
208 'trip_id' => $data['trip_id'] ?? null,
209 'errors' => $e->getErrors(),
210 ]);
211 return [
212 'success' => false,
213 'message' => $e->getMessage() ?: __('Booking validation failed.', 'yatra'),
214 'errors' => $e->getErrors(),
215 ];
216 }
217
218 $data = BookingValidator::sanitize($data);
219
220 // ========================================
221 // SERVER-SIDE AVAILABILITY RESOLUTION (single source of truth)
222 // ========================================
223 // For rule-generated dates we may not have a numeric availability_id. Always re-resolve
224 // by (trip_id, travel_date, departure_time) so capacity/status/cutoff checks match what
225 // the single-trip UI showed.
226 $isWaitlist = isset($data['status']) && $data['status'] === 'waitlist';
227 $tripId = (int) ($data['trip_id'] ?? 0);
228 $travelDate = (string) ($data['travel_date'] ?? ($data['start_date'] ?? ''));
229 $departureTime = null;
230 if (!empty($data['departure_time']) && is_string($data['departure_time'])) {
231 $departureTime = trim($data['departure_time']);
232 if ($departureTime === '') {
233 $departureTime = null;
234 }
235 }
236
237 if (!$isWaitlist && $tripId > 0 && $travelDate !== '') {
238 try {
239 $resolver = new AvailabilityResolutionService();
240 $resolved = $resolver->resolveAvailabilityForDate($tripId, $travelDate, $departureTime);
241
242 $status = (string) ($resolved->status ?? 'available');
243 if (in_array($status, ['blocked', 'closed', 'cancelled'], true)) {
244 return [
245 'success' => false,
246 'message' => __('This departure is not open for booking.', 'yatra'),
247 ];
248 }
249
250 $travelersCount = (int) ($data['travelers_count'] ?? 0);
251 $seatsAvailable = isset($resolved->seats_available) ? (int) $resolved->seats_available : null;
252 if ($status === 'sold_out' || ($seatsAvailable !== null && $seatsAvailable <= 0)) {
253 return [
254 'success' => false,
255 'message' => __('This departure is sold out.', 'yatra'),
256 ];
257 }
258 if ($seatsAvailable !== null && $travelersCount > 0 && $travelersCount > $seatsAvailable) {
259 return [
260 'success' => false,
261 'message' => __('This departure is full.', 'yatra'),
262 ];
263 }
264
265 // Cutoff enforcement (best-effort; recurring generation already filters by cutoff).
266 $cutoffHours = isset($resolved->cutoff_hours) ? (int) $resolved->cutoff_hours : 0;
267 if ($cutoffHours > 0 && !empty($resolved->departure_date)) {
268 $dt = (string) $resolved->departure_date;
269 if (!empty($resolved->departure_time)) {
270 $dt .= ' ' . (string) $resolved->departure_time;
271 } else {
272 $dt .= ' 00:00';
273 }
274 $depTs = strtotime($dt);
275 if ($depTs !== false) {
276 $latest = $depTs - ($cutoffHours * 3600);
277 if (time() > $latest) {
278 return [
279 'success' => false,
280 'message' => __('Booking cutoff has passed for this departure.', 'yatra'),
281 ];
282 }
283 }
284 }
285
286 // Persist a snapshot of the resolved source for auditing and downstream modules.
287 $meta = [];
288 if (!empty($data['meta']) && is_string($data['meta'])) {
289 $decoded = json_decode($data['meta'], true);
290 $meta = is_array($decoded) ? $decoded : [];
291 } elseif (is_array($data['meta'] ?? null)) {
292 $meta = $data['meta'];
293 }
294 $meta['resolved_availability'] = [
295 'source' => $resolved->source ?? null,
296 'rule_id' => $resolved->rule_id ?? null,
297 'availability_id' => $resolved->id ?? null,
298 'departure_date' => $resolved->departure_date ?? null,
299 'departure_time' => $resolved->departure_time ?? null,
300 'pricing_type' => $resolved->pricing_type ?? null,
301 'price_types' => $resolved->price_types ?? [],
302 ];
303 $data['meta'] = wp_json_encode($meta);
304 } catch (\Throwable $e) {
305 // If resolution fails, allow booking to proceed using legacy validations.
306 }
307 }
308
309 Logger::info("After BookingValidator::sanitize", [
310 'data_keys' => array_keys($data),
311 'has_itinerary_costs' => isset($data['itinerary_costs']),
312 'has_itinerary_costs_total' => isset($data['itinerary_costs_total'])
313 ]);
314
315 // Business rule validations
316 $validationResult = $this->validateBookingBusinessRules($data);
317 if (!$validationResult['success']) {
318 Logger::warning("Booking business rule validation failed", [
319 'trip_id' => $data['trip_id'],
320 'reason' => $validationResult['message']
321 ]);
322 return $validationResult;
323 }
324
325 // Validate trip exists and is available
326 $trip = $this->tripRepository->find((int) $data['trip_id']);
327 if (!$trip) {
328 Logger::error("Trip not found for booking", ['trip_id' => $data['trip_id']]);
329 return ['success' => false, 'message' => __('Trip not found.', 'yatra')];
330 }
331
332 if ($trip->status !== 'publish') {
333 Logger::warning("Trip not available for booking", [
334 'trip_id' => $data['trip_id'],
335 'status' => $trip->status
336 ]);
337 return ['success' => false, 'message' => __('Trip is not available for booking.', 'yatra')];
338 }
339
340 // Calculate start_date and end_date if travel_date is provided
341 if (!empty($data['travel_date']) && empty($data['start_date'])) {
342 $data['start_date'] = $data['travel_date'];
343 }
344
345 if (!empty($data['start_date']) && empty($data['end_date'])) {
346 $data['end_date'] = $this->calculateEndDate($data['start_date'], (int) $data['trip_id']);
347 }
348
349 // Generate unique reference
350 $data['reference'] = $this->bookingRepository->generateReference();
351
352 // Find or create customer
353 if (!empty($data['contact_email'])) {
354 $customerId = $this->customerRepository->findOrCreate([
355 'email' => $data['contact_email'],
356 'first_name' => $data['contact_first_name'] ?? '',
357 'last_name' => $data['contact_last_name'] ?? '',
358 'phone' => $data['contact_phone'] ?? '',
359 'country' => $data['contact_country'] ?? '',
360 'user_id' => $data['user_id'] ?? null,
361 ]);
362 $data['customer_id'] = $customerId;
363 }
364
365 // Apply tax calculation to booking data
366 $data = BookingTaxService::applyTaxToBooking($data);
367
368 // Calculate amounts (recalculated after tax)
369 $data['amount_due'] = (float) ($data['total_amount'] ?? 0) - (float) ($data['amount_paid'] ?? 0);
370
371 // Create booking
372 $bookingId = $this->bookingRepository->create($data);
373
374 if (!$bookingId) {
375 Logger::error("Failed to create booking in database", ['data' => $data]);
376 return ['success' => false, 'message' => __('Failed to create booking.', 'yatra')];
377 }
378
379 // Waitlist bookings do not consume departure capacity until promoted.
380 $isWaitlist = isset($data['status']) && $data['status'] === 'waitlist';
381
382 // Link booking to departure if start_date is provided
383 if (!$isWaitlist && !empty($data['start_date']) && !empty($data['end_date'])) {
384 try {
385 $trip = $this->tripRepository->find((int) $data['trip_id']);
386 // Get max capacity from trip's max_travelers, or use default
387 $maxCapacity = null;
388 if ($trip && !empty($trip->max_travelers)) {
389 $maxCapacity = (int) $trip->max_travelers;
390 }
391 $travelersCount = (int) ($data['travelers_count'] ?? 0);
392
393 $departureTime = null;
394 if (!empty($data['departure_time']) && is_string($data['departure_time'])) {
395 $departureTime = trim($data['departure_time']);
396 if ($departureTime === '') {
397 $departureTime = null;
398 }
399 }
400
401 // Find or create departure
402 $departure = $this->departureService->findOrCreateForBooking(
403 (int) $data['trip_id'],
404 $data['start_date'],
405 $data['end_date'],
406 $travelersCount,
407 $maxCapacity,
408 $departureTime
409 );
410
411 // Link booking to departure
412 $this->departureService->linkBookingToDeparture($bookingId, $departure->id);
413
414 // Increment booked count
415 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
416
417 Logger::info("Booking linked to departure", [
418 'booking_id' => $bookingId,
419 'departure_id' => $departure->id
420 ]);
421 } catch (\Exception $e) {
422 // Log error but don't fail the booking
423 Logger::warning("Failed to link booking to departure", [
424 'booking_id' => $bookingId,
425 'error' => $e->getMessage()
426 ]);
427 }
428 }
429
430 // Save travelers
431 if (!empty($data['travelers']) && is_array($data['travelers'])) {
432 $this->saveTravelers($bookingId, $data['travelers']);
433 }
434
435 // Customer confirmation: skip when checkout will send the session email (offline / zero due).
436 if (!$skipInitialCustomerConfirmation) {
437 $this->sendBookingConfirmationEmail($bookingId);
438 }
439
440 $executionTime = microtime(true) - $startTime;
441 Logger::info("Booking created successfully", [
442 'booking_id' => $bookingId,
443 'reference' => $data['reference'],
444 'execution_time' => $executionTime
445 ]);
446
447 $booking = $this->bookingRepository->find((int) $bookingId);
448 if (!is_object($booking)) {
449 $booking = (object) [];
450 }
451
452 // Defer the public booking-created action when the row is
453 // still in `pending_verification`. Sending the booking
454 // confirmation email and firing analytics integrations
455 // before the customer has proven the email is theirs would
456 // (a) leak the booking details to whoever owns that
457 // address, and (b) inflate conversion metrics with bookings
458 // that may never be verified. BookingSessionController::
459 // verify_email() re-fires this action after the status flip
460 // so every listener (NotificationHooks, EmailAutomation,
461 // analytics modules) still runs — just *after* verification.
462 //
463 // Inventory + cache invalidation aren't routed through this
464 // action (they're called directly above), so seat-holding
465 // continues to work while the customer is in the holding
466 // state.
467 $bookingStatus = (string) ($data['status'] ?? ($booking->status ?? ''));
468 if ($bookingStatus !== 'pending_verification') {
469 do_action(\Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED, (int) $bookingId, $booking);
470 }
471
472 return [
473 'success' => true,
474 'booking_id' => $bookingId,
475 'reference' => $data['reference'],
476 'message' => __('Booking created successfully.', 'yatra'),
477 ];
478
479 } catch (\Exception $e) {
480 $executionTime = microtime(true) - $startTime;
481 Logger::error("Booking creation failed", [
482 'trip_id' => $data['trip_id'] ?? null,
483 'execution_time' => $executionTime,
484 'error' => $e->getMessage()
485 ]);
486
487 return [
488 'success' => false,
489 'message' => $e->getMessage()
490 ];
491 }
492 }
493
494 /**
495 * Update a booking
496 *
497 * @param int $id Booking ID
498 * @param array $data Booking data
499 * @return array {success: bool, message: string}
500 */
501 public function updateBooking(int $id, array $data): array
502 {
503 $booking = $this->bookingRepository->find($id);
504
505 if (!$booking) {
506 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
507 }
508
509 // Check if date is being changed
510 $oldStartDate = $booking->start_date ?? $booking->travel_date ?? null;
511 $newStartDate = $data['start_date'] ?? $data['travel_date'] ?? null;
512 $dateChanged = false;
513
514 if ($newStartDate && $oldStartDate && $newStartDate !== $oldStartDate) {
515 $dateChanged = true;
516 }
517
518 // Calculate end_date if start_date is provided
519 if (!empty($data['start_date']) && empty($data['end_date'])) {
520 $data['end_date'] = $this->calculateEndDate($data['start_date'], (int) $booking->trip_id);
521 } elseif (!empty($data['travel_date']) && empty($data['start_date']) && empty($data['end_date'])) {
522 $data['start_date'] = $data['travel_date'];
523 $data['end_date'] = $this->calculateEndDate($data['travel_date'], (int) $booking->trip_id);
524 }
525
526 $oldStatus = (string) ($booking->status ?? '');
527
528 // Update booking
529 $updated = $this->bookingRepository->update($id, $data);
530
531 if (!$updated) {
532 return ['success' => false, 'message' => __('Failed to update booking.', 'yatra')];
533 }
534
535 $newStatus = isset($data['status']) ? (string) $data['status'] : null;
536 if ($newStatus !== null && $oldStatus === 'waitlist' && $newStatus !== 'waitlist') {
537 WaitlistService::releaseWaitlistHolding($booking);
538 }
539
540 // Handle departure date change if date was changed
541 if ($dateChanged && !empty($data['start_date']) && !empty($data['end_date'])) {
542 try {
543 $this->departureService->handleBookingDateChange(
544 $id,
545 $data['start_date'],
546 $data['end_date']
547 );
548 } catch (\Exception $e) {
549 // Log error but don't fail the update
550 }
551 }
552
553 // Update travelers if provided
554 if (isset($data['travelers']) && is_array($data['travelers'])) {
555 // Delete existing travelers
556 $this->travellerRepository->deleteByBookingId($id);
557 // Save new travelers
558 $this->saveTravelers($id, $data['travelers']);
559 }
560
561 return [
562 'success' => true,
563 'message' => __('Booking updated successfully.', 'yatra'),
564 ];
565 }
566
567 /**
568 * Calculate end date from start date and trip duration
569 *
570 * @param string $startDate Start date (YYYY-MM-DD)
571 * @param int $tripId Trip ID
572 * @return string End date (YYYY-MM-DD)
573 */
574 private function calculateEndDate(string $startDate, int $tripId): string
575 {
576 return $this->bookingRepository->calculateEndDate($startDate, $tripId);
577 }
578
579 /**
580 * Update booking status
581 *
582 * @param int $id Booking ID
583 * @param string $status New status
584 * @return array {success: bool, message: string}
585 */
586 public function updateStatus(int $id, string $status): array
587 {
588 $validStatuses = ['pending', 'confirmed', 'processing', 'completed', 'cancelled', 'refunded', 'failed', 'on_hold'];
589
590 if (!in_array($status, $validStatuses, true)) {
591 return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
592 }
593
594 $booking = $this->bookingRepository->find($id);
595
596 if (!$booking) {
597 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
598 }
599
600 $oldStatus = $booking->status;
601 $updated = $this->bookingRepository->updateStatus($id, $status);
602
603 if (!$updated) {
604 return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
605 }
606
607 if ($oldStatus === 'waitlist' && $status !== 'waitlist') {
608 WaitlistService::releaseWaitlistHolding($booking);
609 }
610
611 // ========================================
612 // HANDLE DEPARTURE BOOKED_COUNT UPDATE
613 // ========================================
614 // If booking is cancelled or refunded, unlink from departure and decrement booked_count
615 // If booking status changes from cancelled/refunded to active, link and increment booked_count
616 try {
617 $departure = $this->departureService->getDepartureForBooking($id);
618 $travelersCount = (int) ($booking->travelers_count ?? 0);
619
620 if ($departure) {
621 // If booking is being cancelled or refunded
622 if (in_array($status, ['cancelled', 'refunded'], true) &&
623 !in_array($oldStatus, ['cancelled', 'refunded'], true)) {
624 // Unlink booking from departure (this will handle cancellation if no bookings remain)
625 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
626 }
627 // If booking status changes from cancelled/refunded back to active
628 elseif (in_array($oldStatus, ['cancelled', 'refunded'], true) &&
629 !in_array($status, ['cancelled', 'refunded'], true)) {
630 // Ensure booking is linked and increment booked count
631 $this->departureService->linkBookingToDeparture($id, $departure->id);
632 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
633 }
634 } elseif (!empty($booking->start_date) || !empty($booking->travel_date)) {
635 // Booking doesn't have a departure yet, but has a date - create and link
636 $startDate = $booking->start_date ?? $booking->travel_date;
637 $endDate = $booking->end_date ?? $this->calculateEndDate($startDate, (int) $booking->trip_id);
638
639 $trip = $this->tripRepository->find((int) $booking->trip_id);
640 $maxCapacity = $trip ? ($trip->max_capacity ?? 9999) : 9999;
641
642 $departure = $this->departureService->findOrCreateForBooking(
643 (int) $booking->trip_id,
644 $startDate,
645 $endDate,
646 $travelersCount,
647 $maxCapacity
648 );
649
650 $this->departureService->linkBookingToDeparture($id, $departure->id);
651 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
652 }
653 } catch (\Exception $e) {
654 // Log error but don't fail the status update
655 }
656
657 // Send status change notification
658 $this->sendStatusChangeNotification($id, $oldStatus, $status);
659
660 /**
661 * Action: Booking status changed
662 * Fires when booking status changes
663 *
664 * @param int $id The booking ID
665 * @param string $oldStatus Previous status
666 * @param string $status New status
667 * @since 3.0.0
668 */
669 do_action('yatra_booking_status_changed', $id, $oldStatus, $status);
670
671 if ($status === 'confirmed' && $oldStatus !== 'confirmed') {
672 \yatra_trigger_booking_confirmed($id, $oldStatus);
673 }
674
675 return [
676 'success' => true,
677 'message' => sprintf(
678 /* translators: %s: new booking status. */
679 __('Booking status updated to %s.', 'yatra'),
680 $status
681 ),
682 ];
683 }
684
685 /**
686 * Delete a booking
687 *
688 * @param int $id Booking ID
689 * @return array {success: bool, message: string}
690 */
691 public function deleteBooking(int $id): array
692 {
693 $booking = $this->bookingRepository->find($id);
694
695 if (!$booking) {
696 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
697 }
698
699 if (($booking->status ?? '') === 'waitlist') {
700 WaitlistService::releaseWaitlistHolding($booking);
701 }
702
703 try {
704 $departure = $this->departureService->getDepartureForBooking($id);
705 if ($departure) {
706 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
707 }
708 } catch (\Throwable $e) {
709 // Continue with delete
710 }
711
712 // Delete related travelers
713 $this->travellerRepository->deleteByBookingId($id);
714
715 // Delete booking
716 $deleted = $this->bookingRepository->delete($id);
717
718 if (!$deleted) {
719 return ['success' => false, 'message' => __('Failed to delete booking.', 'yatra')];
720 }
721
722 if (!is_object($booking)) {
723 $booking = (object) [];
724 }
725
726 do_action('yatra_booking_deleted', (int) $id, $booking);
727
728 return [
729 'success' => true,
730 'message' => __('Booking deleted successfully.', 'yatra'),
731 ];
732 }
733
734 /**
735 * Get booking statistics
736 *
737 * @return array
738 */
739 public function getStats(): array
740 {
741 return $this->bookingRepository->getStats();
742 }
743
744 /**
745 * Get booking payments
746 *
747 * @param int $bookingId Booking ID
748 * @return array
749 */
750 public function getBookingPayments(int $bookingId): array
751 {
752 return $this->paymentRepository->findByBookingId($bookingId);
753 }
754
755 /**
756 * Get booking travelers
757 *
758 * @param int $bookingId Booking ID
759 * @return array
760 */
761 public function getBookingTravelers(int $bookingId): array
762 {
763 return $this->travellerRepository->getByBookingId($bookingId);
764 }
765
766 /**
767 * Format booking for API response
768 *
769 * @param object $booking Raw booking data
770 * @return array
771 */
772 private function formatBooking(object $booking): array
773 {
774 // Build customer name from contact fields
775 $customerName = trim(
776 ($booking->customer_first_name ?? $booking->contact_first_name ?? '') . ' ' . ($booking->customer_last_name ?? $booking->contact_last_name ?? '')
777 ) ?: ($booking->customer_name ?? $booking->customer_email ?? $booking->contact_email ?? null);
778
779 $customerEmail = $booking->customer_email ?? $booking->contact_email ?? null;
780 $customerPhone = $booking->contact_phone ?? null;
781
782 // Fallback: fetch customer record if customer_id is set and info missing
783 if (($booking->customer_id ?? 0) && (empty($customerName) || empty($customerEmail) || empty($customerPhone))) {
784 $customerRepo = new \Yatra\Repositories\CustomerRepository();
785 $customerRecord = $customerRepo->find((int)$booking->customer_id);
786 if ($customerRecord) {
787 if (empty($customerName)) {
788 $customerName = trim(($customerRecord->first_name ?? '') . ' ' . ($customerRecord->last_name ?? '')) ?: ($customerRecord->email ?? $customerName);
789 }
790 if (empty($customerEmail)) {
791 $customerEmail = $customerRecord->email ?? $customerEmail;
792 }
793 if (empty($customerPhone)) {
794 $customerPhone = $customerRecord->phone ?? $customerPhone;
795 }
796 if (empty($booking->contact_first_name) && !empty($customerRecord->first_name)) {
797 $booking->contact_first_name = $customerRecord->first_name;
798 }
799 if (empty($booking->contact_last_name) && !empty($customerRecord->last_name)) {
800 $booking->contact_last_name = $customerRecord->last_name;
801 }
802 if (empty($booking->contact_country) && !empty($customerRecord->country)) {
803 $booking->contact_country = $customerRecord->country;
804 }
805 }
806 }
807
808 return [
809 'id' => (int) $booking->id,
810 'reference' => $booking->reference,
811 // UI expects booking_number and booking_status fields
812 'booking_number' => $booking->reference,
813 'booking_status' => $booking->status,
814 'trip_id' => (int) $booking->trip_id,
815 'trip_title' => $booking->trip_title ?? '',
816 'trip_slug' => $booking->trip_slug ?? '',
817 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
818 'user_id' => $booking->user_id ? (int) $booking->user_id : null,
819 'customer_name' => $customerName,
820 'customer_email' => $customerEmail,
821 'customer_phone' => $customerPhone,
822 'contact' => [
823 'first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
824 'last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
825 'email' => $customerEmail,
826 'phone' => $customerPhone,
827 'country' => $booking->contact_country,
828 ],
829 'contact_first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
830 'contact_last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
831 'contact_email' => $customerEmail,
832 'contact_phone' => $customerPhone,
833 'contact_country' => $booking->contact_country ?? null,
834 'travel_date' => $booking->travel_date,
835 'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
836 'end_date' => $booking->end_date ?? null,
837 // travelers_count stored; also fallback to total_travelers/travelers if present
838 'travelers_count' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
839 'travelers' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
840 'total_amount' => (float) $booking->total_amount,
841 'amount_paid' => (float) $booking->amount_paid,
842 'amount_due' => (float) $booking->amount_due,
843 'discount_amount' => (float) ($booking->discount_amount ?? 0),
844 'discount_code' => $booking->discount_code ?? null,
845 'currency' => $booking->currency,
846 'tax_amount' => (float) ($booking->tax_amount ?? 0),
847 'tax_rate' => (float) ($booking->tax_rate ?? 0),
848 'tax_inclusive' => (int) ($booking->tax_inclusive ?? 0),
849 'tax_details' => $booking->tax_details ?? null,
850 'tax_breakdown' => $booking->tax_details ? json_decode($booking->tax_details, true) : [],
851 'subtotal' => (float) ($booking->subtotal ?? $booking->total_amount ?? 0),
852 'taxable_amount' => (float) (($booking->subtotal ?? $booking->total_amount ?? 0) + ($booking->itinerary_costs_total ?? 0)),
853 'itinerary_costs' => $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [],
854 'itinerary_costs_total' => (float) ($booking->itinerary_costs_total ?? 0),
855 'status' => $booking->status,
856 'payment_status' => $booking->payment_status,
857 // Some UIs expect payment_method; map from payment_gateway
858 'payment_gateway' => $booking->payment_gateway,
859 'payment_method' => $booking->payment_gateway,
860 // booking_date is used in admin table; map to created_at
861 'booking_date' => $booking->created_at,
862 'created_at' => $booking->created_at,
863 'updated_at' => $booking->updated_at,
864 ];
865 }
866
867 /**
868 * Format booking with all details for single view
869 *
870 * @param object $booking Raw booking data
871 * @return array
872 */
873 private function formatBookingWithDetails(object $booking): array
874 {
875 $formatted = $this->formatBooking($booking);
876
877 // Add customer name for easier access
878 $formatted['customer_name'] = trim(
879 ($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? '')
880 ) ?: null;
881 $formatted['customer_email'] = $booking->contact_email ?? null;
882 $formatted['customer_phone'] = $booking->contact_phone ?? null;
883
884 // Also add contact fields at root level for backward compatibility
885 $formatted['contact_first_name'] = $booking->contact_first_name ?? null;
886 $formatted['contact_last_name'] = $booking->contact_last_name ?? null;
887 $formatted['contact_email'] = $booking->contact_email ?? null;
888 $formatted['contact_phone'] = $booking->contact_phone ?? null;
889 $formatted['contact_country'] = $booking->contact_country ?? null;
890
891 // Add full contact data
892 $formatted['contact_data'] = $booking->contact_data ? json_decode($booking->contact_data, true) : null;
893
894 // Add emergency contact: handle JSON, serialized, or array
895 $emergency = $booking->emergency_contact ?? null;
896 if (is_string($emergency)) {
897 $decoded = json_decode($emergency, true);
898 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
899 $emergency = $decoded;
900 } else {
901 $maybe = maybe_unserialize($emergency);
902 $emergency = is_array($maybe) ? $maybe : null;
903 }
904 } elseif (!is_array($emergency)) {
905 $emergency = null;
906 }
907 $formatted['emergency_contact'] = $emergency;
908
909 // Add travelers
910 $formatted['travelers'] = $this->getBookingTravelers((int) $booking->id);
911
912 // Add payments
913 $formatted['payments'] = $this->getBookingPayments((int) $booking->id);
914
915 // Add tax breakdown
916 $formatted['tax_breakdown'] = BookingTaxService::getBookingTaxBreakdown((array) $formatted);
917 $formatted['tax_display'] = BookingTaxService::formatBookingTaxDisplay((array) $formatted);
918
919 // Add itinerary costs
920 $formatted['itinerary_costs'] = $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [];
921 $formatted['itinerary_costs_total'] = (float) ($booking->itinerary_costs_total ?? 0);
922
923 // Add additional fields
924 $formatted['special_requests'] = $booking->special_requests;
925 $formatted['internal_notes'] = $booking->internal_notes;
926 $formatted['payment_transaction_id'] = $booking->payment_transaction_id;
927 $formatted['cancelled_at'] = $booking->cancelled_at;
928 $formatted['cancellation_reason'] = $booking->cancellation_reason;
929 $formatted['confirmed_at'] = $booking->confirmed_at;
930 $formatted['completed_at'] = $booking->completed_at;
931
932 /**
933 * Filter: Add additional services to booking details
934 * Allows premium modules to include services data in booking response
935 *
936 * @param array $services Empty array by default
937 * @param int $booking_id The booking ID
938 * @since 3.0.0
939 */
940 $formatted['additional_services'] = apply_filters('yatra_booking_get_services', [], (int) $booking->id);
941
942 $formatted = apply_filters('yatra_booking_details', $formatted, (int) $booking->id);
943
944 return $formatted;
945 }
946
947 /**
948 * Save travelers for a booking
949 *
950 * @param int $bookingId Booking ID
951 * @param array $travelers Travelers data
952 */
953 private function saveTravelers(int $bookingId, array $travelers): void
954 {
955 foreach ($travelers as $index => $travelerData) {
956 $isLead = $index === 0;
957 $this->travellerRepository->createTraveller($bookingId, $index, $isLead, $travelerData);
958 }
959 }
960
961 /**
962 * Transactional "new booking" email (settings / Pro templates). Used by checkout when the session
963 * defers email until after payment redirect or sends the rich HTML confirmation at the end.
964 */
965 public function sendNewBookingTransactionalConfirmation(int $bookingId): void
966 {
967 $this->sendBookingConfirmationEmail($bookingId);
968 }
969
970 /**
971 * Send booking confirmation email
972 *
973 * @param int $bookingId Booking ID
974 */
975 private function sendBookingConfirmationEmail(int $bookingId): void
976 {
977 $booking = $this->bookingRepository->findWithTrip($bookingId);
978
979 if (!$booking || empty($booking->contact_email)) {
980 return;
981 }
982
983 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
984 $vars['intro_paragraph'] = __('Thank you for your booking! Here are your details:', 'yatra');
985 $vars['transactional_context'] = 'booking_created';
986
987 TransactionalEmailTemplateService::sendIfEnabled(
988 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
989 $booking->contact_email,
990 $vars
991 );
992 }
993
994 /**
995 * Send status change notification
996 *
997 * @param int $bookingId Booking ID
998 * @param string $oldStatus Previous status
999 * @param string $newStatus New status
1000 */
1001 private function sendStatusChangeNotification(int $bookingId, string $oldStatus, string $newStatus): void
1002 {
1003 // Only send for certain status changes
1004 $notifyStatuses = ['confirmed', 'cancelled', 'completed'];
1005
1006 if (!in_array($newStatus, $notifyStatuses, true)) {
1007 return;
1008 }
1009
1010 $booking = $this->bookingRepository->findWithTrip($bookingId);
1011
1012 if (!$booking || empty($booking->contact_email)) {
1013 return;
1014 }
1015
1016 if ($newStatus === 'cancelled') {
1017 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1018 $vars['cancellation_reason'] = (string) ($booking->cancellation_reason ?? '');
1019 TransactionalEmailTemplateService::sendIfEnabled(
1020 TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
1021 $booking->contact_email,
1022 $vars
1023 );
1024
1025 return;
1026 }
1027
1028 if ($newStatus === 'confirmed') {
1029 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1030 $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra');
1031 $vars['transactional_context'] = 'status_confirmed';
1032 TransactionalEmailTemplateService::sendIfEnabled(
1033 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1034 $booking->contact_email,
1035 $vars
1036 );
1037
1038 return;
1039 }
1040
1041 if ($newStatus === 'completed') {
1042 $handled = apply_filters('yatra_send_booking_status_email_html', null, $bookingId, $oldStatus, $newStatus, $booking);
1043 if ($handled !== null) {
1044 ReviewReminderService::scheduleReminder($bookingId);
1045
1046 return;
1047 }
1048
1049 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1050 $vars['completion_date'] = date_i18n(get_option('date_format'));
1051 TransactionalEmailTemplateService::sendIfEnabled(
1052 TransactionalEmailTemplateService::TYPE_BOOKING_COMPLETED,
1053 $booking->contact_email,
1054 $vars
1055 );
1056
1057 ReviewReminderService::scheduleReminder($bookingId);
1058
1059 return;
1060 }
1061 }
1062
1063 /**
1064 * Get all travelers with pagination
1065 *
1066 * @param array $filters Filters
1067 * @return array
1068 */
1069 public function getTravelers(array $filters = []): array
1070 {
1071 return $this->travellerRepository->paginate($filters);
1072 }
1073
1074 /**
1075 * Perform bulk actions on travelers
1076 *
1077 * Currently supports only delete.
1078 *
1079 * @param int[] $ids Traveler IDs
1080 * @param string $action Action key (e.g. 'delete')
1081 * @return array {success: bool, message: string}
1082 */
1083 public function bulkTravelers(array $ids, string $action): array
1084 {
1085 $action = trim($action);
1086
1087 if ($action !== 'delete') {
1088 return [
1089 'success' => false,
1090 'message' => __('Invalid traveler bulk action.', 'yatra'),
1091 ];
1092 }
1093
1094 return $this->travellerRepository->bulkDelete($ids);
1095 }
1096
1097 /**
1098 * Send booking email
1099 *
1100 * @param int $bookingId Booking ID
1101 * @param string $emailType Email type (confirmation, reminder, etc.)
1102 * @return array {success: bool, message: string}
1103 */
1104 public function sendEmail(int $bookingId, string $emailType = 'confirmation'): array
1105 {
1106 $booking = $this->bookingRepository->findWithTrip($bookingId);
1107
1108 if (!$booking) {
1109 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
1110 }
1111
1112 if (empty($booking->contact_email)) {
1113 return ['success' => false, 'message' => __('No email address found.', 'yatra')];
1114 }
1115
1116 switch ($emailType) {
1117 case 'confirmation':
1118 $this->sendBookingConfirmationEmail($bookingId);
1119 break;
1120
1121 case 'reminder':
1122 $this->sendBookingReminderEmail($booking);
1123 break;
1124
1125 default:
1126 return ['success' => false, 'message' => __('Unknown email type.', 'yatra')];
1127 }
1128
1129 return [
1130 'success' => true,
1131 'message' => __('Email sent successfully.', 'yatra'),
1132 ];
1133 }
1134
1135 /**
1136 * Send booking reminder email
1137 *
1138 * @param object $booking Booking data
1139 */
1140 private function sendBookingReminderEmail(object $booking): void
1141 {
1142 $daysUntilTrip = (int) ((strtotime((string) $booking->travel_date) - time()) / 86400);
1143 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1144 $vars['days_until_trip'] = (string) max(0, $daysUntilTrip);
1145 $vars['reminder_days'] = (string) SettingsService::getInt('booking_reminder_days', 3);
1146
1147 $checklist = '<p><strong>' . esc_html__('Preparation checklist', 'yatra') . '</strong></p><ul>'
1148 . '<li>' . esc_html__('Valid government-issued ID', 'yatra') . '</li>'
1149 . '<li>' . esc_html__('Travel insurance', 'yatra') . '</li>'
1150 . '<li>' . esc_html__('Emergency contacts', 'yatra') . '</li>'
1151 . '</ul>';
1152 $vars['reminder_extra_html'] = $checklist;
1153
1154 $sent = TransactionalEmailTemplateService::sendIfEnabled(
1155 TransactionalEmailTemplateService::TYPE_BOOKING_REMINDER,
1156 $booking->contact_email,
1157 $vars
1158 );
1159
1160 if ($sent) {
1161 $this->bookingRepository->update((int) $booking->id, [
1162 'reminder_sent' => 1,
1163 'reminder_sent_at' => current_time('mysql'),
1164 ]);
1165 }
1166 }
1167 }
1168
1169