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

1,245 lines 50.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 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 $oldPaymentStatus = (string) ($booking->payment_status ?? '');
544
545 // Update booking
546 $updated = $this->bookingRepository->update($id, $data);
547
548 if (!$updated) {
549 return ['success' => false, 'message' => __('Failed to update booking.', 'yatra')];
550 }
551
552 $newStatus = isset($data['status']) ? (string) $data['status'] : null;
553 if ($newStatus !== null && $oldStatus === 'waitlist' && $newStatus !== 'waitlist') {
554 WaitlistService::releaseWaitlistHolding($booking);
555 }
556
557 // Handle departure date change if date was changed
558 if ($dateChanged && !empty($data['start_date']) && !empty($data['end_date'])) {
559 try {
560 $this->departureService->handleBookingDateChange(
561 $id,
562 $data['start_date'],
563 $data['end_date']
564 );
565 } catch (\Exception $e) {
566 // Log error but don't fail the update
567 }
568 }
569
570 // Update travelers if provided
571 if (isset($data['travelers']) && is_array($data['travelers'])) {
572 // Delete existing travelers
573 $this->travellerRepository->deleteByBookingId($id);
574 // Save new travelers
575 $this->saveTravelers($id, $data['travelers']);
576 }
577
578 // A manual payment-status change (e.g. an admin marking an offline
579 // bank-transfer booking as Paid) fired no notification and no hook before,
580 // so the customer was never told their payment was received. Detect the
581 // change and notify — without firing `yatra_payment_completed` (that means
582 // a real gateway charge and carries capture side effects).
583 $newPaymentStatus = isset($data['payment_status']) ? (string) $data['payment_status'] : null;
584 if ($newPaymentStatus !== null && $newPaymentStatus !== $oldPaymentStatus) {
585 $this->handlePaymentStatusChange($id, $oldPaymentStatus, $newPaymentStatus);
586 }
587
588 // Return the fresh booking so the REST controller's `$result['data']`
589 // is defined (previously absent → "Undefined array key data" warning).
590 return [
591 'success' => true,
592 'message' => __('Booking updated successfully.', 'yatra'),
593 'data' => $this->bookingRepository->find($id),
594 ];
595 }
596
597 /**
598 * React to a manual payment-status change (admin edits, e.g. bank transfer
599 * marked Paid). Sends the customer + admin payment emails when money is
600 * (fully or partially) received, and fires `yatra_payment_status_changed`
601 * so integrations can react. Intentionally separate from
602 * `yatra_payment_completed`, which represents a real gateway capture.
603 */
604 private function handlePaymentStatusChange(int $bookingId, string $oldStatus, string $newStatus): void
605 {
606 $booking = $this->bookingRepository->findWithTrip($bookingId);
607 if (!$booking) {
608 return;
609 }
610
611 do_action('yatra_payment_status_changed', $bookingId, $oldStatus, $newStatus, $booking);
612
613 if (in_array($newStatus, ['paid', 'partial'], true)) {
614 $paidAmount = (float) ($booking->amount_paid ?? 0);
615 if ($paidAmount <= 0) {
616 $paidAmount = (float) ($booking->total_amount ?? 0);
617 }
618 \Yatra\Services\NotificationService::sendPaymentCompletedNotification([
619 'booking_id' => $bookingId,
620 'amount' => $paidAmount,
621 'payment_method' => (string) ($booking->payment_method ?? ''),
622 'transaction_id' => '',
623 ]);
624 }
625 }
626
627 /**
628 * Calculate end date from start date and trip duration
629 *
630 * @param string $startDate Start date (YYYY-MM-DD)
631 * @param int $tripId Trip ID
632 * @return string End date (YYYY-MM-DD)
633 */
634 private function calculateEndDate(string $startDate, int $tripId): string
635 {
636 return $this->bookingRepository->calculateEndDate($startDate, $tripId);
637 }
638
639 /**
640 * Update booking status
641 *
642 * @param int $id Booking ID
643 * @param string $status New status
644 * @return array {success: bool, message: string}
645 */
646 public function updateStatus(int $id, string $status): array
647 {
648 $validStatuses = ['pending', 'confirmed', 'processing', 'completed', 'cancelled', 'refunded', 'failed', 'on_hold'];
649
650 if (!in_array($status, $validStatuses, true)) {
651 return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
652 }
653
654 $booking = $this->bookingRepository->find($id);
655
656 if (!$booking) {
657 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
658 }
659
660 $oldStatus = $booking->status;
661 $updated = $this->bookingRepository->updateStatus($id, $status);
662
663 if (!$updated) {
664 return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
665 }
666
667 if ($oldStatus === 'waitlist' && $status !== 'waitlist') {
668 WaitlistService::releaseWaitlistHolding($booking);
669 }
670
671 // ========================================
672 // HANDLE DEPARTURE BOOKED_COUNT UPDATE
673 // ========================================
674 // If booking is cancelled or refunded, unlink from departure and decrement booked_count
675 // If booking status changes from cancelled/refunded to active, link and increment booked_count
676 try {
677 $departure = $this->departureService->getDepartureForBooking($id);
678 $travelersCount = (int) ($booking->travelers_count ?? 0);
679
680 if ($departure) {
681 // If booking is being cancelled or refunded
682 if (in_array($status, ['cancelled', 'refunded'], true) &&
683 !in_array($oldStatus, ['cancelled', 'refunded'], true)) {
684 // Unlink booking from departure (this will handle cancellation if no bookings remain)
685 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
686 }
687 // If booking status changes from cancelled/refunded back to active
688 elseif (in_array($oldStatus, ['cancelled', 'refunded'], true) &&
689 !in_array($status, ['cancelled', 'refunded'], true)) {
690 // Ensure booking is linked and increment booked count
691 $this->departureService->linkBookingToDeparture($id, $departure->id);
692 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
693 }
694 } elseif (!empty($booking->start_date) || !empty($booking->travel_date)) {
695 // Booking doesn't have a departure yet, but has a date - create and link
696 $startDate = $booking->start_date ?? $booking->travel_date;
697 $endDate = $booking->end_date ?? $this->calculateEndDate($startDate, (int) $booking->trip_id);
698
699 $trip = $this->tripRepository->find((int) $booking->trip_id);
700 $maxCapacity = $trip ? ($trip->max_capacity ?? 9999) : 9999;
701
702 $departure = $this->departureService->findOrCreateForBooking(
703 (int) $booking->trip_id,
704 $startDate,
705 $endDate,
706 $travelersCount,
707 $maxCapacity
708 );
709
710 $this->departureService->linkBookingToDeparture($id, $departure->id);
711 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
712 }
713 } catch (\Exception $e) {
714 // Log error but don't fail the status update
715 }
716
717 // Send status change notification
718 $this->sendStatusChangeNotification($id, $oldStatus, $status);
719
720 /**
721 * Action: Booking status changed
722 * Fires when booking status changes
723 *
724 * @param int $id The booking ID
725 * @param string $oldStatus Previous status
726 * @param string $status New status
727 * @since 3.0.0
728 */
729 do_action('yatra_booking_status_changed', $id, $oldStatus, $status);
730
731 if ($status === 'confirmed' && $oldStatus !== 'confirmed') {
732 \yatra_trigger_booking_confirmed($id, $oldStatus);
733 }
734
735 return [
736 'success' => true,
737 'message' => sprintf(
738 /* translators: %s: new booking status. */
739 __('Booking status updated to %s.', 'yatra'),
740 $status
741 ),
742 ];
743 }
744
745 /**
746 * Delete a booking
747 *
748 * @param int $id Booking ID
749 * @return array {success: bool, message: string}
750 */
751 public function deleteBooking(int $id): array
752 {
753 $booking = $this->bookingRepository->find($id);
754
755 if (!$booking) {
756 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
757 }
758
759 if (($booking->status ?? '') === 'waitlist') {
760 WaitlistService::releaseWaitlistHolding($booking);
761 }
762
763 try {
764 $departure = $this->departureService->getDepartureForBooking($id);
765 if ($departure) {
766 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
767 }
768 } catch (\Throwable $e) {
769 // Continue with delete
770 }
771
772 // Delete related travelers
773 $this->travellerRepository->deleteByBookingId($id);
774
775 // Delete booking
776 $deleted = $this->bookingRepository->delete($id);
777
778 if (!$deleted) {
779 return ['success' => false, 'message' => __('Failed to delete booking.', 'yatra')];
780 }
781
782 if (!is_object($booking)) {
783 $booking = (object) [];
784 }
785
786 do_action('yatra_booking_deleted', (int) $id, $booking);
787
788 return [
789 'success' => true,
790 'message' => __('Booking deleted successfully.', 'yatra'),
791 ];
792 }
793
794 /**
795 * Get booking statistics
796 *
797 * @return array
798 */
799 public function getStats(): array
800 {
801 return $this->bookingRepository->getStats();
802 }
803
804 /**
805 * Get booking payments
806 *
807 * @param int $bookingId Booking ID
808 * @return array
809 */
810 public function getBookingPayments(int $bookingId): array
811 {
812 return $this->paymentRepository->findByBookingId($bookingId);
813 }
814
815 /**
816 * Get booking travelers
817 *
818 * @param int $bookingId Booking ID
819 * @return array
820 */
821 public function getBookingTravelers(int $bookingId): array
822 {
823 return $this->travellerRepository->getByBookingId($bookingId);
824 }
825
826 /**
827 * Format booking for API response
828 *
829 * @param object $booking Raw booking data
830 * @return array
831 */
832 private function formatBooking(object $booking): array
833 {
834 // Build customer name from contact fields
835 $customerName = trim(
836 ($booking->customer_first_name ?? $booking->contact_first_name ?? '') . ' ' . ($booking->customer_last_name ?? $booking->contact_last_name ?? '')
837 ) ?: ($booking->customer_name ?? $booking->customer_email ?? $booking->contact_email ?? null);
838
839 $customerEmail = $booking->customer_email ?? $booking->contact_email ?? null;
840 $customerPhone = $booking->contact_phone ?? null;
841
842 // Fallback: fetch customer record if customer_id is set and info missing
843 if (($booking->customer_id ?? 0) && (empty($customerName) || empty($customerEmail) || empty($customerPhone))) {
844 $customerRepo = new \Yatra\Repositories\CustomerRepository();
845 $customerRecord = $customerRepo->find((int)$booking->customer_id);
846 if ($customerRecord) {
847 if (empty($customerName)) {
848 $customerName = trim(($customerRecord->first_name ?? '') . ' ' . ($customerRecord->last_name ?? '')) ?: ($customerRecord->email ?? $customerName);
849 }
850 if (empty($customerEmail)) {
851 $customerEmail = $customerRecord->email ?? $customerEmail;
852 }
853 if (empty($customerPhone)) {
854 $customerPhone = $customerRecord->phone ?? $customerPhone;
855 }
856 if (empty($booking->contact_first_name) && !empty($customerRecord->first_name)) {
857 $booking->contact_first_name = $customerRecord->first_name;
858 }
859 if (empty($booking->contact_last_name) && !empty($customerRecord->last_name)) {
860 $booking->contact_last_name = $customerRecord->last_name;
861 }
862 if (empty($booking->contact_country) && !empty($customerRecord->country)) {
863 $booking->contact_country = $customerRecord->country;
864 }
865 }
866 }
867
868 return [
869 'id' => (int) $booking->id,
870 'reference' => $booking->reference,
871 // UI expects booking_number and booking_status fields
872 'booking_number' => $booking->reference,
873 'booking_status' => $booking->status,
874 'trip_id' => (int) $booking->trip_id,
875 'trip_title' => $booking->trip_title ?? '',
876 'trip_slug' => $booking->trip_slug ?? '',
877 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
878 'user_id' => $booking->user_id ? (int) $booking->user_id : null,
879 'customer_name' => $customerName,
880 'customer_email' => $customerEmail,
881 'customer_phone' => $customerPhone,
882 'contact' => [
883 'first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
884 'last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
885 'email' => $customerEmail,
886 'phone' => $customerPhone,
887 'country' => $booking->contact_country,
888 ],
889 'contact_first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
890 'contact_last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
891 'contact_email' => $customerEmail,
892 'contact_phone' => $customerPhone,
893 'contact_country' => $booking->contact_country ?? null,
894 'travel_date' => $booking->travel_date,
895 'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
896 'end_date' => $booking->end_date ?? null,
897 // travelers_count stored; also fallback to total_travelers/travelers if present
898 'travelers_count' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
899 'travelers' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
900 'total_amount' => (float) $booking->total_amount,
901 'amount_paid' => (float) $booking->amount_paid,
902 'amount_due' => (float) $booking->amount_due,
903 'discount_amount' => (float) ($booking->discount_amount ?? 0),
904 'discount_code' => $booking->discount_code ?? null,
905 'currency' => $booking->currency,
906 'tax_amount' => (float) ($booking->tax_amount ?? 0),
907 'tax_rate' => (float) ($booking->tax_rate ?? 0),
908 'tax_inclusive' => (int) ($booking->tax_inclusive ?? 0),
909 'tax_details' => $booking->tax_details ?? null,
910 'tax_breakdown' => $booking->tax_details ? json_decode($booking->tax_details, true) : [],
911 'subtotal' => (float) ($booking->subtotal ?? $booking->total_amount ?? 0),
912 'taxable_amount' => (float) (($booking->subtotal ?? $booking->total_amount ?? 0) + ($booking->itinerary_costs_total ?? 0)),
913 'itinerary_costs' => $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [],
914 'itinerary_costs_total' => (float) ($booking->itinerary_costs_total ?? 0),
915 'status' => $booking->status,
916 'payment_status' => $booking->payment_status,
917 // Some UIs expect payment_method; map from payment_gateway
918 'payment_gateway' => $booking->payment_gateway,
919 'payment_method' => $booking->payment_gateway,
920 // booking_date is used in admin table; map to created_at
921 'booking_date' => $booking->created_at,
922 'created_at' => $booking->created_at,
923 'updated_at' => $booking->updated_at,
924 ];
925 }
926
927 /**
928 * Format booking with all details for single view
929 *
930 * @param object $booking Raw booking data
931 * @return array
932 */
933 private function formatBookingWithDetails(object $booking): array
934 {
935 $formatted = $this->formatBooking($booking);
936
937 // Add customer name for easier access
938 $formatted['customer_name'] = trim(
939 ($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? '')
940 ) ?: null;
941 $formatted['customer_email'] = $booking->contact_email ?? null;
942 $formatted['customer_phone'] = $booking->contact_phone ?? null;
943
944 // Also add contact fields at root level for backward compatibility
945 $formatted['contact_first_name'] = $booking->contact_first_name ?? null;
946 $formatted['contact_last_name'] = $booking->contact_last_name ?? null;
947 $formatted['contact_email'] = $booking->contact_email ?? null;
948 $formatted['contact_phone'] = $booking->contact_phone ?? null;
949 $formatted['contact_country'] = $booking->contact_country ?? null;
950
951 // Add full contact data
952 $formatted['contact_data'] = $booking->contact_data ? json_decode($booking->contact_data, true) : null;
953
954 // Add emergency contact: handle JSON, serialized, or array
955 $emergency = $booking->emergency_contact ?? null;
956 if (is_string($emergency)) {
957 $decoded = json_decode($emergency, true);
958 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
959 $emergency = $decoded;
960 } else {
961 $maybe = maybe_unserialize($emergency);
962 $emergency = is_array($maybe) ? $maybe : null;
963 }
964 } elseif (!is_array($emergency)) {
965 $emergency = null;
966 }
967 $formatted['emergency_contact'] = $emergency;
968
969 // Add travelers
970 $formatted['travelers'] = $this->getBookingTravelers((int) $booking->id);
971
972 // Add payments
973 $formatted['payments'] = $this->getBookingPayments((int) $booking->id);
974
975 // Add tax breakdown
976 $formatted['tax_breakdown'] = BookingTaxService::getBookingTaxBreakdown((array) $formatted);
977 $formatted['tax_display'] = BookingTaxService::formatBookingTaxDisplay((array) $formatted);
978
979 // Add itinerary costs
980 $formatted['itinerary_costs'] = $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [];
981 $formatted['itinerary_costs_total'] = (float) ($booking->itinerary_costs_total ?? 0);
982
983 // Add additional fields
984 $formatted['special_requests'] = $booking->special_requests;
985 $formatted['internal_notes'] = $booking->internal_notes;
986 $formatted['payment_transaction_id'] = $booking->payment_transaction_id;
987 $formatted['cancelled_at'] = $booking->cancelled_at;
988 $formatted['cancellation_reason'] = $booking->cancellation_reason;
989 $formatted['confirmed_at'] = $booking->confirmed_at;
990 $formatted['completed_at'] = $booking->completed_at;
991
992 /**
993 * Filter: Add additional services to booking details
994 * Allows premium modules to include services data in booking response
995 *
996 * @param array $services Empty array by default
997 * @param int $booking_id The booking ID
998 * @since 3.0.0
999 */
1000 $formatted['additional_services'] = apply_filters('yatra_booking_get_services', [], (int) $booking->id);
1001
1002 $formatted = apply_filters('yatra_booking_details', $formatted, (int) $booking->id);
1003
1004 return $formatted;
1005 }
1006
1007 /**
1008 * Save travelers for a booking
1009 *
1010 * @param int $bookingId Booking ID
1011 * @param array $travelers Travelers data
1012 */
1013 private function saveTravelers(int $bookingId, array $travelers): void
1014 {
1015 // Re-index defensively so traveller_index / is_lead are positional and
1016 // contiguous regardless of the incoming keys.
1017 $index = 0;
1018 foreach ($travelers as $travelerData) {
1019 if (!is_array($travelerData)) {
1020 continue;
1021 }
1022 $isLead = $index === 0;
1023 // Accept both shapes: a nested { fields: {...} } (repository format)
1024 // or a flat field map (admin BookingForm). Drop non-field meta keys.
1025 $fields = isset($travelerData['fields']) && is_array($travelerData['fields'])
1026 ? $travelerData['fields']
1027 : $travelerData;
1028 unset($fields['is_lead'], $fields['traveller_index'], $fields['id'], $fields['booking_id']);
1029 // create() is the real repository method (createTraveller() never existed);
1030 // it inserts the traveller row and writes every field to the meta table —
1031 // the same method the checkout flow uses.
1032 $this->travellerRepository->create($bookingId, $index, $isLead, $fields);
1033 $index++;
1034 }
1035 }
1036
1037 /**
1038 * Transactional "new booking" email (settings / Pro templates). Used by checkout when the session
1039 * defers email until after payment redirect or sends the rich HTML confirmation at the end.
1040 */
1041 public function sendNewBookingTransactionalConfirmation(int $bookingId): void
1042 {
1043 $this->sendBookingConfirmationEmail($bookingId);
1044 }
1045
1046 /**
1047 * Send booking confirmation email
1048 *
1049 * @param int $bookingId Booking ID
1050 */
1051 private function sendBookingConfirmationEmail(int $bookingId): void
1052 {
1053 $booking = $this->bookingRepository->findWithTrip($bookingId);
1054
1055 if (!$booking || empty($booking->contact_email)) {
1056 return;
1057 }
1058
1059 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1060 $vars['intro_paragraph'] = __('Thank you for your booking! Here are your details:', 'yatra');
1061 $vars['transactional_context'] = 'booking_created';
1062
1063 TransactionalEmailTemplateService::sendIfEnabled(
1064 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1065 $booking->contact_email,
1066 $vars
1067 );
1068 }
1069
1070 /**
1071 * Send status change notification
1072 *
1073 * @param int $bookingId Booking ID
1074 * @param string $oldStatus Previous status
1075 * @param string $newStatus New status
1076 */
1077 private function sendStatusChangeNotification(int $bookingId, string $oldStatus, string $newStatus): void
1078 {
1079 // Only send for certain status changes
1080 $notifyStatuses = ['confirmed', 'cancelled', 'completed'];
1081
1082 if (!in_array($newStatus, $notifyStatuses, true)) {
1083 return;
1084 }
1085
1086 $booking = $this->bookingRepository->findWithTrip($bookingId);
1087
1088 if (!$booking || empty($booking->contact_email)) {
1089 return;
1090 }
1091
1092 if ($newStatus === 'cancelled') {
1093 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1094 $vars['cancellation_reason'] = (string) ($booking->cancellation_reason ?? '');
1095 TransactionalEmailTemplateService::sendIfEnabled(
1096 TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
1097 $booking->contact_email,
1098 $vars
1099 );
1100
1101 return;
1102 }
1103
1104 if ($newStatus === 'confirmed') {
1105 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1106 $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra');
1107 $vars['transactional_context'] = 'status_confirmed';
1108 TransactionalEmailTemplateService::sendIfEnabled(
1109 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1110 $booking->contact_email,
1111 $vars
1112 );
1113
1114 return;
1115 }
1116
1117 if ($newStatus === 'completed') {
1118 $handled = apply_filters('yatra_send_booking_status_email_html', null, $bookingId, $oldStatus, $newStatus, $booking);
1119 if ($handled !== null) {
1120 ReviewReminderService::scheduleReminder($bookingId);
1121
1122 return;
1123 }
1124
1125 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1126 $vars['completion_date'] = date_i18n(get_option('date_format'));
1127 TransactionalEmailTemplateService::sendIfEnabled(
1128 TransactionalEmailTemplateService::TYPE_BOOKING_COMPLETED,
1129 $booking->contact_email,
1130 $vars
1131 );
1132
1133 ReviewReminderService::scheduleReminder($bookingId);
1134
1135 return;
1136 }
1137 }
1138
1139 /**
1140 * Get all travelers with pagination
1141 *
1142 * @param array $filters Filters
1143 * @return array
1144 */
1145 public function getTravelers(array $filters = []): array
1146 {
1147 return $this->travellerRepository->paginate($filters);
1148 }
1149
1150 /**
1151 * Perform bulk actions on travelers
1152 *
1153 * Currently supports only delete.
1154 *
1155 * @param int[] $ids Traveler IDs
1156 * @param string $action Action key (e.g. 'delete')
1157 * @return array {success: bool, message: string}
1158 */
1159 public function bulkTravelers(array $ids, string $action): array
1160 {
1161 $action = trim($action);
1162
1163 if ($action !== 'delete') {
1164 return [
1165 'success' => false,
1166 'message' => __('Invalid traveler bulk action.', 'yatra'),
1167 ];
1168 }
1169
1170 return $this->travellerRepository->bulkDelete($ids);
1171 }
1172
1173 /**
1174 * Send booking email
1175 *
1176 * @param int $bookingId Booking ID
1177 * @param string $emailType Email type (confirmation, reminder, etc.)
1178 * @return array {success: bool, message: string}
1179 */
1180 public function sendEmail(int $bookingId, string $emailType = 'confirmation'): array
1181 {
1182 $booking = $this->bookingRepository->findWithTrip($bookingId);
1183
1184 if (!$booking) {
1185 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
1186 }
1187
1188 if (empty($booking->contact_email)) {
1189 return ['success' => false, 'message' => __('No email address found.', 'yatra')];
1190 }
1191
1192 switch ($emailType) {
1193 case 'confirmation':
1194 $this->sendBookingConfirmationEmail($bookingId);
1195 break;
1196
1197 case 'reminder':
1198 $this->sendBookingReminderEmail($booking);
1199 break;
1200
1201 default:
1202 return ['success' => false, 'message' => __('Unknown email type.', 'yatra')];
1203 }
1204
1205 return [
1206 'success' => true,
1207 'message' => __('Email sent successfully.', 'yatra'),
1208 ];
1209 }
1210
1211 /**
1212 * Send booking reminder email
1213 *
1214 * @param object $booking Booking data
1215 */
1216 private function sendBookingReminderEmail(object $booking): void
1217 {
1218 $daysUntilTrip = (int) ((strtotime((string) $booking->travel_date) - time()) / 86400);
1219 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1220 $vars['days_until_trip'] = (string) max(0, $daysUntilTrip);
1221 $vars['reminder_days'] = (string) SettingsService::getInt('booking_reminder_days', 3);
1222
1223 $checklist = '<p><strong>' . esc_html__('Preparation checklist', 'yatra') . '</strong></p><ul>'
1224 . '<li>' . esc_html__('Valid government-issued ID', 'yatra') . '</li>'
1225 . '<li>' . esc_html__('Travel insurance', 'yatra') . '</li>'
1226 . '<li>' . esc_html__('Emergency contacts', 'yatra') . '</li>'
1227 . '</ul>';
1228 $vars['reminder_extra_html'] = $checklist;
1229
1230 $sent = TransactionalEmailTemplateService::sendIfEnabled(
1231 TransactionalEmailTemplateService::TYPE_BOOKING_REMINDER,
1232 $booking->contact_email,
1233 $vars
1234 );
1235
1236 if ($sent) {
1237 $this->bookingRepository->update((int) $booking->id, [
1238 'reminder_sent' => 1,
1239 'reminder_sent_at' => current_time('mysql'),
1240 ]);
1241 }
1242 }
1243 }
1244
1245