PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.7
Yatra – Travel Booking & Tour Operator Software v3.0.7
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 2.0.11 All 82 releases
yatra / app / Services / BookingService.php

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

1,201 lines 48.3 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 amount due (recalculated after tax). Honor the selected
369 // payment method via Pro FlexiblePayments so a deposit/partial
370 // booking stores the reduced amount due now — not the full total.
371 // For 'full' (and when Pro is inactive) the filter returns
372 // total − paid unchanged, so full-payment bookings are unaffected.
373 // After the deposit is paid, payment completion resets amount_due to
374 // the remaining balance (total − amount_paid).
375 $bs_total = (float) ($data['total_amount'] ?? 0);
376 $bs_paid = (float) ($data['amount_paid'] ?? 0);
377 $bs_payment_method = strtolower(trim((string) ($data['payment_method'] ?? 'full')));
378 $bs_due_now = (float) apply_filters(
379 'yatra_calculate_amount_due',
380 $bs_total - $bs_paid,
381 $bs_total,
382 $bs_payment_method,
383 ['trip_id' => (int) ($data['trip_id'] ?? 0)]
384 );
385 $data['amount_due'] = max(0.0, round($bs_due_now, 2));
386
387 // Create booking
388 $bookingId = $this->bookingRepository->create($data);
389
390 if (!$bookingId) {
391 Logger::error("Failed to create booking in database", ['data' => $data]);
392 return ['success' => false, 'message' => __('Failed to create booking.', 'yatra')];
393 }
394
395 // Waitlist bookings do not consume departure capacity until promoted.
396 $isWaitlist = isset($data['status']) && $data['status'] === 'waitlist';
397
398 // Link booking to departure if start_date is provided
399 if (!$isWaitlist && !empty($data['start_date']) && !empty($data['end_date'])) {
400 try {
401 $trip = $this->tripRepository->find((int) $data['trip_id']);
402 // Get max capacity from trip's max_travelers, or use default
403 $maxCapacity = null;
404 if ($trip && !empty($trip->max_travelers)) {
405 $maxCapacity = (int) $trip->max_travelers;
406 }
407 $travelersCount = (int) ($data['travelers_count'] ?? 0);
408
409 $departureTime = null;
410 if (!empty($data['departure_time']) && is_string($data['departure_time'])) {
411 $departureTime = trim($data['departure_time']);
412 if ($departureTime === '') {
413 $departureTime = null;
414 }
415 }
416
417 // Find or create departure
418 $departure = $this->departureService->findOrCreateForBooking(
419 (int) $data['trip_id'],
420 $data['start_date'],
421 $data['end_date'],
422 $travelersCount,
423 $maxCapacity,
424 $departureTime
425 );
426
427 // Link booking to departure
428 $this->departureService->linkBookingToDeparture($bookingId, $departure->id);
429
430 // Increment booked count
431 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
432
433 Logger::info("Booking linked to departure", [
434 'booking_id' => $bookingId,
435 'departure_id' => $departure->id
436 ]);
437 } catch (\Exception $e) {
438 // Log error but don't fail the booking
439 Logger::warning("Failed to link booking to departure", [
440 'booking_id' => $bookingId,
441 'error' => $e->getMessage()
442 ]);
443 }
444 }
445
446 // Save travelers
447 if (!empty($data['travelers']) && is_array($data['travelers'])) {
448 $this->saveTravelers($bookingId, $data['travelers']);
449 }
450
451 // Customer confirmation: skip when checkout will send the session email (offline / zero due).
452 if (!$skipInitialCustomerConfirmation) {
453 $this->sendBookingConfirmationEmail($bookingId);
454 }
455
456 $executionTime = microtime(true) - $startTime;
457 Logger::info("Booking created successfully", [
458 'booking_id' => $bookingId,
459 'reference' => $data['reference'],
460 'execution_time' => $executionTime
461 ]);
462
463 $booking = $this->bookingRepository->find((int) $bookingId);
464 if (!is_object($booking)) {
465 $booking = (object) [];
466 }
467
468 // Defer the public booking-created action when the row is
469 // still in `pending_verification`. Sending the booking
470 // confirmation email and firing analytics integrations
471 // before the customer has proven the email is theirs would
472 // (a) leak the booking details to whoever owns that
473 // address, and (b) inflate conversion metrics with bookings
474 // that may never be verified. BookingSessionController::
475 // verify_email() re-fires this action after the status flip
476 // so every listener (NotificationHooks, EmailAutomation,
477 // analytics modules) still runs — just *after* verification.
478 //
479 // Inventory + cache invalidation aren't routed through this
480 // action (they're called directly above), so seat-holding
481 // continues to work while the customer is in the holding
482 // state.
483 $bookingStatus = (string) ($data['status'] ?? ($booking->status ?? ''));
484 if ($bookingStatus !== 'pending_verification') {
485 do_action(\Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED, (int) $bookingId, $booking);
486 }
487
488 return [
489 'success' => true,
490 'booking_id' => $bookingId,
491 'reference' => $data['reference'],
492 'message' => __('Booking created successfully.', 'yatra'),
493 ];
494
495 } catch (\Exception $e) {
496 $executionTime = microtime(true) - $startTime;
497 Logger::error("Booking creation failed", [
498 'trip_id' => $data['trip_id'] ?? null,
499 'execution_time' => $executionTime,
500 'error' => $e->getMessage()
501 ]);
502
503 return [
504 'success' => false,
505 'message' => $e->getMessage()
506 ];
507 }
508 }
509
510 /**
511 * Update a booking
512 *
513 * @param int $id Booking ID
514 * @param array $data Booking data
515 * @return array {success: bool, message: string}
516 */
517 public function updateBooking(int $id, array $data): array
518 {
519 $booking = $this->bookingRepository->find($id);
520
521 if (!$booking) {
522 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
523 }
524
525 // Check if date is being changed
526 $oldStartDate = $booking->start_date ?? $booking->travel_date ?? null;
527 $newStartDate = $data['start_date'] ?? $data['travel_date'] ?? null;
528 $dateChanged = false;
529
530 if ($newStartDate && $oldStartDate && $newStartDate !== $oldStartDate) {
531 $dateChanged = true;
532 }
533
534 // Calculate end_date if start_date is provided
535 if (!empty($data['start_date']) && empty($data['end_date'])) {
536 $data['end_date'] = $this->calculateEndDate($data['start_date'], (int) $booking->trip_id);
537 } elseif (!empty($data['travel_date']) && empty($data['start_date']) && empty($data['end_date'])) {
538 $data['start_date'] = $data['travel_date'];
539 $data['end_date'] = $this->calculateEndDate($data['travel_date'], (int) $booking->trip_id);
540 }
541
542 $oldStatus = (string) ($booking->status ?? '');
543
544 // Update booking
545 $updated = $this->bookingRepository->update($id, $data);
546
547 if (!$updated) {
548 return ['success' => false, 'message' => __('Failed to update booking.', 'yatra')];
549 }
550
551 $newStatus = isset($data['status']) ? (string) $data['status'] : null;
552 if ($newStatus !== null && $oldStatus === 'waitlist' && $newStatus !== 'waitlist') {
553 WaitlistService::releaseWaitlistHolding($booking);
554 }
555
556 // Handle departure date change if date was changed
557 if ($dateChanged && !empty($data['start_date']) && !empty($data['end_date'])) {
558 try {
559 $this->departureService->handleBookingDateChange(
560 $id,
561 $data['start_date'],
562 $data['end_date']
563 );
564 } catch (\Exception $e) {
565 // Log error but don't fail the update
566 }
567 }
568
569 // Update travelers if provided
570 if (isset($data['travelers']) && is_array($data['travelers'])) {
571 // Delete existing travelers
572 $this->travellerRepository->deleteByBookingId($id);
573 // Save new travelers
574 $this->saveTravelers($id, $data['travelers']);
575 }
576
577 return [
578 'success' => true,
579 'message' => __('Booking updated successfully.', 'yatra'),
580 ];
581 }
582
583 /**
584 * Calculate end date from start date and trip duration
585 *
586 * @param string $startDate Start date (YYYY-MM-DD)
587 * @param int $tripId Trip ID
588 * @return string End date (YYYY-MM-DD)
589 */
590 private function calculateEndDate(string $startDate, int $tripId): string
591 {
592 return $this->bookingRepository->calculateEndDate($startDate, $tripId);
593 }
594
595 /**
596 * Update booking status
597 *
598 * @param int $id Booking ID
599 * @param string $status New status
600 * @return array {success: bool, message: string}
601 */
602 public function updateStatus(int $id, string $status): array
603 {
604 $validStatuses = ['pending', 'confirmed', 'processing', 'completed', 'cancelled', 'refunded', 'failed', 'on_hold'];
605
606 if (!in_array($status, $validStatuses, true)) {
607 return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
608 }
609
610 $booking = $this->bookingRepository->find($id);
611
612 if (!$booking) {
613 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
614 }
615
616 $oldStatus = $booking->status;
617 $updated = $this->bookingRepository->updateStatus($id, $status);
618
619 if (!$updated) {
620 return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
621 }
622
623 if ($oldStatus === 'waitlist' && $status !== 'waitlist') {
624 WaitlistService::releaseWaitlistHolding($booking);
625 }
626
627 // ========================================
628 // HANDLE DEPARTURE BOOKED_COUNT UPDATE
629 // ========================================
630 // If booking is cancelled or refunded, unlink from departure and decrement booked_count
631 // If booking status changes from cancelled/refunded to active, link and increment booked_count
632 try {
633 $departure = $this->departureService->getDepartureForBooking($id);
634 $travelersCount = (int) ($booking->travelers_count ?? 0);
635
636 if ($departure) {
637 // If booking is being cancelled or refunded
638 if (in_array($status, ['cancelled', 'refunded'], true) &&
639 !in_array($oldStatus, ['cancelled', 'refunded'], true)) {
640 // Unlink booking from departure (this will handle cancellation if no bookings remain)
641 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
642 }
643 // If booking status changes from cancelled/refunded back to active
644 elseif (in_array($oldStatus, ['cancelled', 'refunded'], true) &&
645 !in_array($status, ['cancelled', 'refunded'], true)) {
646 // Ensure booking is linked and increment booked count
647 $this->departureService->linkBookingToDeparture($id, $departure->id);
648 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
649 }
650 } elseif (!empty($booking->start_date) || !empty($booking->travel_date)) {
651 // Booking doesn't have a departure yet, but has a date - create and link
652 $startDate = $booking->start_date ?? $booking->travel_date;
653 $endDate = $booking->end_date ?? $this->calculateEndDate($startDate, (int) $booking->trip_id);
654
655 $trip = $this->tripRepository->find((int) $booking->trip_id);
656 $maxCapacity = $trip ? ($trip->max_capacity ?? 9999) : 9999;
657
658 $departure = $this->departureService->findOrCreateForBooking(
659 (int) $booking->trip_id,
660 $startDate,
661 $endDate,
662 $travelersCount,
663 $maxCapacity
664 );
665
666 $this->departureService->linkBookingToDeparture($id, $departure->id);
667 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
668 }
669 } catch (\Exception $e) {
670 // Log error but don't fail the status update
671 }
672
673 // Send status change notification
674 $this->sendStatusChangeNotification($id, $oldStatus, $status);
675
676 /**
677 * Action: Booking status changed
678 * Fires when booking status changes
679 *
680 * @param int $id The booking ID
681 * @param string $oldStatus Previous status
682 * @param string $status New status
683 * @since 3.0.0
684 */
685 do_action('yatra_booking_status_changed', $id, $oldStatus, $status);
686
687 if ($status === 'confirmed' && $oldStatus !== 'confirmed') {
688 \yatra_trigger_booking_confirmed($id, $oldStatus);
689 }
690
691 return [
692 'success' => true,
693 'message' => sprintf(
694 /* translators: %s: new booking status. */
695 __('Booking status updated to %s.', 'yatra'),
696 $status
697 ),
698 ];
699 }
700
701 /**
702 * Delete a booking
703 *
704 * @param int $id Booking ID
705 * @return array {success: bool, message: string}
706 */
707 public function deleteBooking(int $id): array
708 {
709 $booking = $this->bookingRepository->find($id);
710
711 if (!$booking) {
712 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
713 }
714
715 if (($booking->status ?? '') === 'waitlist') {
716 WaitlistService::releaseWaitlistHolding($booking);
717 }
718
719 try {
720 $departure = $this->departureService->getDepartureForBooking($id);
721 if ($departure) {
722 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
723 }
724 } catch (\Throwable $e) {
725 // Continue with delete
726 }
727
728 // Delete related travelers
729 $this->travellerRepository->deleteByBookingId($id);
730
731 // Delete booking
732 $deleted = $this->bookingRepository->delete($id);
733
734 if (!$deleted) {
735 return ['success' => false, 'message' => __('Failed to delete booking.', 'yatra')];
736 }
737
738 if (!is_object($booking)) {
739 $booking = (object) [];
740 }
741
742 do_action('yatra_booking_deleted', (int) $id, $booking);
743
744 return [
745 'success' => true,
746 'message' => __('Booking deleted successfully.', 'yatra'),
747 ];
748 }
749
750 /**
751 * Get booking statistics
752 *
753 * @return array
754 */
755 public function getStats(): array
756 {
757 return $this->bookingRepository->getStats();
758 }
759
760 /**
761 * Get booking payments
762 *
763 * @param int $bookingId Booking ID
764 * @return array
765 */
766 public function getBookingPayments(int $bookingId): array
767 {
768 return $this->paymentRepository->findByBookingId($bookingId);
769 }
770
771 /**
772 * Get booking travelers
773 *
774 * @param int $bookingId Booking ID
775 * @return array
776 */
777 public function getBookingTravelers(int $bookingId): array
778 {
779 return $this->travellerRepository->getByBookingId($bookingId);
780 }
781
782 /**
783 * Format booking for API response
784 *
785 * @param object $booking Raw booking data
786 * @return array
787 */
788 private function formatBooking(object $booking): array
789 {
790 // Build customer name from contact fields
791 $customerName = trim(
792 ($booking->customer_first_name ?? $booking->contact_first_name ?? '') . ' ' . ($booking->customer_last_name ?? $booking->contact_last_name ?? '')
793 ) ?: ($booking->customer_name ?? $booking->customer_email ?? $booking->contact_email ?? null);
794
795 $customerEmail = $booking->customer_email ?? $booking->contact_email ?? null;
796 $customerPhone = $booking->contact_phone ?? null;
797
798 // Fallback: fetch customer record if customer_id is set and info missing
799 if (($booking->customer_id ?? 0) && (empty($customerName) || empty($customerEmail) || empty($customerPhone))) {
800 $customerRepo = new \Yatra\Repositories\CustomerRepository();
801 $customerRecord = $customerRepo->find((int)$booking->customer_id);
802 if ($customerRecord) {
803 if (empty($customerName)) {
804 $customerName = trim(($customerRecord->first_name ?? '') . ' ' . ($customerRecord->last_name ?? '')) ?: ($customerRecord->email ?? $customerName);
805 }
806 if (empty($customerEmail)) {
807 $customerEmail = $customerRecord->email ?? $customerEmail;
808 }
809 if (empty($customerPhone)) {
810 $customerPhone = $customerRecord->phone ?? $customerPhone;
811 }
812 if (empty($booking->contact_first_name) && !empty($customerRecord->first_name)) {
813 $booking->contact_first_name = $customerRecord->first_name;
814 }
815 if (empty($booking->contact_last_name) && !empty($customerRecord->last_name)) {
816 $booking->contact_last_name = $customerRecord->last_name;
817 }
818 if (empty($booking->contact_country) && !empty($customerRecord->country)) {
819 $booking->contact_country = $customerRecord->country;
820 }
821 }
822 }
823
824 return [
825 'id' => (int) $booking->id,
826 'reference' => $booking->reference,
827 // UI expects booking_number and booking_status fields
828 'booking_number' => $booking->reference,
829 'booking_status' => $booking->status,
830 'trip_id' => (int) $booking->trip_id,
831 'trip_title' => $booking->trip_title ?? '',
832 'trip_slug' => $booking->trip_slug ?? '',
833 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
834 'user_id' => $booking->user_id ? (int) $booking->user_id : null,
835 'customer_name' => $customerName,
836 'customer_email' => $customerEmail,
837 'customer_phone' => $customerPhone,
838 'contact' => [
839 'first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
840 'last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
841 'email' => $customerEmail,
842 'phone' => $customerPhone,
843 'country' => $booking->contact_country,
844 ],
845 'contact_first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
846 'contact_last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
847 'contact_email' => $customerEmail,
848 'contact_phone' => $customerPhone,
849 'contact_country' => $booking->contact_country ?? null,
850 'travel_date' => $booking->travel_date,
851 'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
852 'end_date' => $booking->end_date ?? null,
853 // travelers_count stored; also fallback to total_travelers/travelers if present
854 'travelers_count' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
855 'travelers' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
856 'total_amount' => (float) $booking->total_amount,
857 'amount_paid' => (float) $booking->amount_paid,
858 'amount_due' => (float) $booking->amount_due,
859 'discount_amount' => (float) ($booking->discount_amount ?? 0),
860 'discount_code' => $booking->discount_code ?? null,
861 'currency' => $booking->currency,
862 'tax_amount' => (float) ($booking->tax_amount ?? 0),
863 'tax_rate' => (float) ($booking->tax_rate ?? 0),
864 'tax_inclusive' => (int) ($booking->tax_inclusive ?? 0),
865 'tax_details' => $booking->tax_details ?? null,
866 'tax_breakdown' => $booking->tax_details ? json_decode($booking->tax_details, true) : [],
867 'subtotal' => (float) ($booking->subtotal ?? $booking->total_amount ?? 0),
868 'taxable_amount' => (float) (($booking->subtotal ?? $booking->total_amount ?? 0) + ($booking->itinerary_costs_total ?? 0)),
869 'itinerary_costs' => $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [],
870 'itinerary_costs_total' => (float) ($booking->itinerary_costs_total ?? 0),
871 'status' => $booking->status,
872 'payment_status' => $booking->payment_status,
873 // Some UIs expect payment_method; map from payment_gateway
874 'payment_gateway' => $booking->payment_gateway,
875 'payment_method' => $booking->payment_gateway,
876 // booking_date is used in admin table; map to created_at
877 'booking_date' => $booking->created_at,
878 'created_at' => $booking->created_at,
879 'updated_at' => $booking->updated_at,
880 ];
881 }
882
883 /**
884 * Format booking with all details for single view
885 *
886 * @param object $booking Raw booking data
887 * @return array
888 */
889 private function formatBookingWithDetails(object $booking): array
890 {
891 $formatted = $this->formatBooking($booking);
892
893 // Add customer name for easier access
894 $formatted['customer_name'] = trim(
895 ($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? '')
896 ) ?: null;
897 $formatted['customer_email'] = $booking->contact_email ?? null;
898 $formatted['customer_phone'] = $booking->contact_phone ?? null;
899
900 // Also add contact fields at root level for backward compatibility
901 $formatted['contact_first_name'] = $booking->contact_first_name ?? null;
902 $formatted['contact_last_name'] = $booking->contact_last_name ?? null;
903 $formatted['contact_email'] = $booking->contact_email ?? null;
904 $formatted['contact_phone'] = $booking->contact_phone ?? null;
905 $formatted['contact_country'] = $booking->contact_country ?? null;
906
907 // Add full contact data
908 $formatted['contact_data'] = $booking->contact_data ? json_decode($booking->contact_data, true) : null;
909
910 // Add emergency contact: handle JSON, serialized, or array
911 $emergency = $booking->emergency_contact ?? null;
912 if (is_string($emergency)) {
913 $decoded = json_decode($emergency, true);
914 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
915 $emergency = $decoded;
916 } else {
917 $maybe = maybe_unserialize($emergency);
918 $emergency = is_array($maybe) ? $maybe : null;
919 }
920 } elseif (!is_array($emergency)) {
921 $emergency = null;
922 }
923 $formatted['emergency_contact'] = $emergency;
924
925 // Add travelers
926 $formatted['travelers'] = $this->getBookingTravelers((int) $booking->id);
927
928 // Add payments
929 $formatted['payments'] = $this->getBookingPayments((int) $booking->id);
930
931 // Add tax breakdown
932 $formatted['tax_breakdown'] = BookingTaxService::getBookingTaxBreakdown((array) $formatted);
933 $formatted['tax_display'] = BookingTaxService::formatBookingTaxDisplay((array) $formatted);
934
935 // Add itinerary costs
936 $formatted['itinerary_costs'] = $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [];
937 $formatted['itinerary_costs_total'] = (float) ($booking->itinerary_costs_total ?? 0);
938
939 // Add additional fields
940 $formatted['special_requests'] = $booking->special_requests;
941 $formatted['internal_notes'] = $booking->internal_notes;
942 $formatted['payment_transaction_id'] = $booking->payment_transaction_id;
943 $formatted['cancelled_at'] = $booking->cancelled_at;
944 $formatted['cancellation_reason'] = $booking->cancellation_reason;
945 $formatted['confirmed_at'] = $booking->confirmed_at;
946 $formatted['completed_at'] = $booking->completed_at;
947
948 /**
949 * Filter: Add additional services to booking details
950 * Allows premium modules to include services data in booking response
951 *
952 * @param array $services Empty array by default
953 * @param int $booking_id The booking ID
954 * @since 3.0.0
955 */
956 $formatted['additional_services'] = apply_filters('yatra_booking_get_services', [], (int) $booking->id);
957
958 $formatted = apply_filters('yatra_booking_details', $formatted, (int) $booking->id);
959
960 return $formatted;
961 }
962
963 /**
964 * Save travelers for a booking
965 *
966 * @param int $bookingId Booking ID
967 * @param array $travelers Travelers data
968 */
969 private function saveTravelers(int $bookingId, array $travelers): void
970 {
971 // Re-index defensively so traveller_index / is_lead are positional and
972 // contiguous regardless of the incoming keys.
973 $index = 0;
974 foreach ($travelers as $travelerData) {
975 if (!is_array($travelerData)) {
976 continue;
977 }
978 $isLead = $index === 0;
979 // Accept both shapes: a nested { fields: {...} } (repository format)
980 // or a flat field map (admin BookingForm). Drop non-field meta keys.
981 $fields = isset($travelerData['fields']) && is_array($travelerData['fields'])
982 ? $travelerData['fields']
983 : $travelerData;
984 unset($fields['is_lead'], $fields['traveller_index'], $fields['id'], $fields['booking_id']);
985 // create() is the real repository method (createTraveller() never existed);
986 // it inserts the traveller row and writes every field to the meta table —
987 // the same method the checkout flow uses.
988 $this->travellerRepository->create($bookingId, $index, $isLead, $fields);
989 $index++;
990 }
991 }
992
993 /**
994 * Transactional "new booking" email (settings / Pro templates). Used by checkout when the session
995 * defers email until after payment redirect or sends the rich HTML confirmation at the end.
996 */
997 public function sendNewBookingTransactionalConfirmation(int $bookingId): void
998 {
999 $this->sendBookingConfirmationEmail($bookingId);
1000 }
1001
1002 /**
1003 * Send booking confirmation email
1004 *
1005 * @param int $bookingId Booking ID
1006 */
1007 private function sendBookingConfirmationEmail(int $bookingId): void
1008 {
1009 $booking = $this->bookingRepository->findWithTrip($bookingId);
1010
1011 if (!$booking || empty($booking->contact_email)) {
1012 return;
1013 }
1014
1015 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1016 $vars['intro_paragraph'] = __('Thank you for your booking! Here are your details:', 'yatra');
1017 $vars['transactional_context'] = 'booking_created';
1018
1019 TransactionalEmailTemplateService::sendIfEnabled(
1020 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1021 $booking->contact_email,
1022 $vars
1023 );
1024 }
1025
1026 /**
1027 * Send status change notification
1028 *
1029 * @param int $bookingId Booking ID
1030 * @param string $oldStatus Previous status
1031 * @param string $newStatus New status
1032 */
1033 private function sendStatusChangeNotification(int $bookingId, string $oldStatus, string $newStatus): void
1034 {
1035 // Only send for certain status changes
1036 $notifyStatuses = ['confirmed', 'cancelled', 'completed'];
1037
1038 if (!in_array($newStatus, $notifyStatuses, true)) {
1039 return;
1040 }
1041
1042 $booking = $this->bookingRepository->findWithTrip($bookingId);
1043
1044 if (!$booking || empty($booking->contact_email)) {
1045 return;
1046 }
1047
1048 if ($newStatus === 'cancelled') {
1049 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1050 $vars['cancellation_reason'] = (string) ($booking->cancellation_reason ?? '');
1051 TransactionalEmailTemplateService::sendIfEnabled(
1052 TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
1053 $booking->contact_email,
1054 $vars
1055 );
1056
1057 return;
1058 }
1059
1060 if ($newStatus === 'confirmed') {
1061 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1062 $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra');
1063 $vars['transactional_context'] = 'status_confirmed';
1064 TransactionalEmailTemplateService::sendIfEnabled(
1065 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1066 $booking->contact_email,
1067 $vars
1068 );
1069
1070 return;
1071 }
1072
1073 if ($newStatus === 'completed') {
1074 $handled = apply_filters('yatra_send_booking_status_email_html', null, $bookingId, $oldStatus, $newStatus, $booking);
1075 if ($handled !== null) {
1076 ReviewReminderService::scheduleReminder($bookingId);
1077
1078 return;
1079 }
1080
1081 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1082 $vars['completion_date'] = date_i18n(get_option('date_format'));
1083 TransactionalEmailTemplateService::sendIfEnabled(
1084 TransactionalEmailTemplateService::TYPE_BOOKING_COMPLETED,
1085 $booking->contact_email,
1086 $vars
1087 );
1088
1089 ReviewReminderService::scheduleReminder($bookingId);
1090
1091 return;
1092 }
1093 }
1094
1095 /**
1096 * Get all travelers with pagination
1097 *
1098 * @param array $filters Filters
1099 * @return array
1100 */
1101 public function getTravelers(array $filters = []): array
1102 {
1103 return $this->travellerRepository->paginate($filters);
1104 }
1105
1106 /**
1107 * Perform bulk actions on travelers
1108 *
1109 * Currently supports only delete.
1110 *
1111 * @param int[] $ids Traveler IDs
1112 * @param string $action Action key (e.g. 'delete')
1113 * @return array {success: bool, message: string}
1114 */
1115 public function bulkTravelers(array $ids, string $action): array
1116 {
1117 $action = trim($action);
1118
1119 if ($action !== 'delete') {
1120 return [
1121 'success' => false,
1122 'message' => __('Invalid traveler bulk action.', 'yatra'),
1123 ];
1124 }
1125
1126 return $this->travellerRepository->bulkDelete($ids);
1127 }
1128
1129 /**
1130 * Send booking email
1131 *
1132 * @param int $bookingId Booking ID
1133 * @param string $emailType Email type (confirmation, reminder, etc.)
1134 * @return array {success: bool, message: string}
1135 */
1136 public function sendEmail(int $bookingId, string $emailType = 'confirmation'): array
1137 {
1138 $booking = $this->bookingRepository->findWithTrip($bookingId);
1139
1140 if (!$booking) {
1141 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
1142 }
1143
1144 if (empty($booking->contact_email)) {
1145 return ['success' => false, 'message' => __('No email address found.', 'yatra')];
1146 }
1147
1148 switch ($emailType) {
1149 case 'confirmation':
1150 $this->sendBookingConfirmationEmail($bookingId);
1151 break;
1152
1153 case 'reminder':
1154 $this->sendBookingReminderEmail($booking);
1155 break;
1156
1157 default:
1158 return ['success' => false, 'message' => __('Unknown email type.', 'yatra')];
1159 }
1160
1161 return [
1162 'success' => true,
1163 'message' => __('Email sent successfully.', 'yatra'),
1164 ];
1165 }
1166
1167 /**
1168 * Send booking reminder email
1169 *
1170 * @param object $booking Booking data
1171 */
1172 private function sendBookingReminderEmail(object $booking): void
1173 {
1174 $daysUntilTrip = (int) ((strtotime((string) $booking->travel_date) - time()) / 86400);
1175 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1176 $vars['days_until_trip'] = (string) max(0, $daysUntilTrip);
1177 $vars['reminder_days'] = (string) SettingsService::getInt('booking_reminder_days', 3);
1178
1179 $checklist = '<p><strong>' . esc_html__('Preparation checklist', 'yatra') . '</strong></p><ul>'
1180 . '<li>' . esc_html__('Valid government-issued ID', 'yatra') . '</li>'
1181 . '<li>' . esc_html__('Travel insurance', 'yatra') . '</li>'
1182 . '<li>' . esc_html__('Emergency contacts', 'yatra') . '</li>'
1183 . '</ul>';
1184 $vars['reminder_extra_html'] = $checklist;
1185
1186 $sent = TransactionalEmailTemplateService::sendIfEnabled(
1187 TransactionalEmailTemplateService::TYPE_BOOKING_REMINDER,
1188 $booking->contact_email,
1189 $vars
1190 );
1191
1192 if ($sent) {
1193 $this->bookingRepository->update((int) $booking->id, [
1194 'reminder_sent' => 1,
1195 'reminder_sent_at' => current_time('mysql'),
1196 ]);
1197 }
1198 }
1199 }
1200
1201