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

1,543 lines 65.6 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 /**
32 * Accepted payment statuses — mirrors the `payment_status` ENUM on the
33 * bookings table. Anything outside this list is rejected before it reaches
34 * the database, where an unknown value would be silently coerced.
35 */
36 public const PAYMENT_STATUSES = ['pending', 'partial', 'paid', 'refunded', 'failed'];
37
38 private BookingRepository $bookingRepository;
39 private PaymentRepository $paymentRepository;
40 private TravellerRepository $travellerRepository;
41 private CustomerRepository $customerRepository;
42 private TripRepository $tripRepository;
43 private DepartureService $departureService;
44
45 public function __construct()
46 {
47 $this->bookingRepository = new BookingRepository();
48 $this->paymentRepository = new PaymentRepository();
49 $this->travellerRepository = new TravellerRepository();
50 $this->customerRepository = new CustomerRepository();
51 $this->tripRepository = new TripRepository();
52 $this->departureService = new DepartureService(
53 new DepartureRepository(),
54 new BookingDepartureRepository(),
55 $this->bookingRepository,
56 $this->tripRepository
57 );
58 }
59
60 /**
61 * Get paginated bookings
62 *
63 * @param array $filters Filters (page, per_page, status, search, etc.)
64 * @return array
65 */
66 public function getBookings(array $filters = []): array
67 {
68 $result = $this->bookingRepository->paginate($filters);
69
70 // Format each booking
71 $result['data'] = array_map([$this, 'formatBooking'], $result['data']);
72
73 return $result;
74 }
75
76 /**
77 * Get single booking with all related data
78 *
79 * @param int $id Booking ID
80 * @return array|null
81 */
82 public function getBooking(int $id): ?array
83 {
84 $booking = $this->bookingRepository->findWithTrip($id);
85
86 if (!$booking) {
87 return null;
88 }
89
90 $formatted = $this->formatBookingWithDetails($booking);
91
92 return $formatted;
93 }
94
95 /**
96 * Get booking by reference code
97 *
98 * @param string $reference Booking reference
99 * @return array|null
100 */
101 public function getBookingByReference(string $reference): ?array
102 {
103 $booking = $this->bookingRepository->findByReference($reference);
104
105 if (!$booking) {
106 return null;
107 }
108
109 return $this->formatBookingWithDetails($booking);
110 }
111
112 /**
113 * Validate booking business rules
114 */
115 private function validateBookingBusinessRules(array $data): array
116 {
117 // Check minimum travelers count
118 $travelersCount = (int) ($data['travelers_count'] ?? 0);
119 if ($travelersCount <= 0) {
120 return [
121 'success' => false,
122 'message' => __('At least one traveler is required for booking.', 'yatra')
123 ];
124 }
125
126 // Check trip capacity if specified
127 if (!empty($data['trip_id'])) {
128 $trip = $this->tripRepository->find((int) $data['trip_id']);
129 if ($trip && !empty($trip->max_travelers)) {
130 $maxCapacity = (int) $trip->max_travelers;
131 if ($travelersCount > $maxCapacity) {
132 return [
133 'success' => false,
134 'message' => sprintf(
135 /* translators: %d: maximum number of travelers allowed. */
136 __('Maximum %d travelers allowed for this trip.', 'yatra'),
137 $maxCapacity
138 )
139 ];
140 }
141 }
142 }
143
144 // Check booking date is in the future
145 if (!empty($data['start_date'])) {
146 $startDate = strtotime($data['start_date']);
147 $today = strtotime('today');
148
149 if ($startDate < $today) {
150 return [
151 'success' => false,
152 'message' => __('Booking date must be in the future.', 'yatra')
153 ];
154 }
155 }
156
157 // Check total amount is positive
158 $totalAmount = (float) ($data['total_amount'] ?? 0);
159 if ($totalAmount <= 0) {
160 return [
161 'success' => false,
162 'message' => __('Total amount must be greater than zero.', 'yatra')
163 ];
164 }
165
166 // Check payment amount doesn't exceed total
167 $amountPaid = (float) ($data['amount_paid'] ?? 0);
168 if ($amountPaid > $totalAmount) {
169 return [
170 'success' => false,
171 'message' => __('Payment amount cannot exceed total booking amount.', 'yatra')
172 ];
173 }
174
175 // Validate tax configuration
176 $taxValidation = BookingTaxService::validateBookingTax($data);
177 if (!$taxValidation['valid']) {
178 return [
179 'success' => false,
180 'message' => __('Tax configuration error.', 'yatra'),
181 'errors' => $taxValidation['errors']
182 ];
183 }
184
185 return ['success' => true];
186 }
187
188 /**
189 * Create a new booking with comprehensive validation and business rules
190 *
191 * @param array $data Booking data
192 * @return array {success: bool, booking_id?: int, reference?: string, message?: string}
193 */
194 public function createBooking(array $data): array
195 {
196 $startTime = microtime(true);
197
198 // Checkout defers the rich confirmation to BookingSessionController (avoids duplicate customer emails).
199 // Not persisted; stripped by BookingValidator::sanitize().
200 $skipInitialCustomerConfirmation = !empty($data['skip_initial_customer_confirmation']);
201
202 try {
203 Logger::info("Booking creation started", [
204 'data_keys' => array_keys($data),
205 'trip_id' => $data['trip_id'] ?? null,
206 'has_itinerary_costs' => isset($data['itinerary_costs']),
207 'has_itinerary_costs_total' => isset($data['itinerary_costs_total'])
208 ]);
209
210 // Comprehensive validation using BookingValidator
211 try {
212 BookingValidator::validateCreate($data);
213 } catch (\Yatra\Exceptions\ValidationException $e) {
214 Logger::warning('Booking validation failed', [
215 'trip_id' => $data['trip_id'] ?? null,
216 'errors' => $e->getErrors(),
217 ]);
218 return [
219 'success' => false,
220 'message' => $e->getMessage() ?: __('Booking validation failed.', 'yatra'),
221 'errors' => $e->getErrors(),
222 ];
223 }
224
225 $data = BookingValidator::sanitize($data);
226
227 // ========================================
228 // SERVER-SIDE AVAILABILITY RESOLUTION (single source of truth)
229 // ========================================
230 // For rule-generated dates we may not have a numeric availability_id. Always re-resolve
231 // by (trip_id, travel_date, departure_time) so capacity/status/cutoff checks match what
232 // the single-trip UI showed.
233 $isWaitlist = isset($data['status']) && $data['status'] === 'waitlist';
234 $tripId = (int) ($data['trip_id'] ?? 0);
235 $travelDate = (string) ($data['travel_date'] ?? ($data['start_date'] ?? ''));
236 $departureTime = null;
237 if (!empty($data['departure_time']) && is_string($data['departure_time'])) {
238 $departureTime = trim($data['departure_time']);
239 if ($departureTime === '') {
240 $departureTime = null;
241 }
242 }
243
244 if (!$isWaitlist && $tripId > 0 && $travelDate !== '') {
245 try {
246 $resolver = new AvailabilityResolutionService();
247 $resolved = $resolver->resolveAvailabilityForDate($tripId, $travelDate, $departureTime);
248
249 $status = (string) ($resolved->status ?? 'available');
250 if (in_array($status, ['blocked', 'closed', 'cancelled'], true)) {
251 return [
252 'success' => false,
253 'message' => __('This departure is not open for booking.', 'yatra'),
254 ];
255 }
256
257 $travelersCount = (int) ($data['travelers_count'] ?? 0);
258 $seatsAvailable = isset($resolved->seats_available) ? (int) $resolved->seats_available : null;
259 if ($status === 'sold_out' || ($seatsAvailable !== null && $seatsAvailable <= 0)) {
260 return [
261 'success' => false,
262 'message' => __('This departure is sold out.', 'yatra'),
263 ];
264 }
265 if ($seatsAvailable !== null && $travelersCount > 0 && $travelersCount > $seatsAvailable) {
266 return [
267 'success' => false,
268 'message' => __('This departure is full.', 'yatra'),
269 ];
270 }
271
272 // Cutoff enforcement (best-effort; recurring generation already filters by cutoff).
273 $cutoffHours = isset($resolved->cutoff_hours) ? (int) $resolved->cutoff_hours : 0;
274 if ($cutoffHours > 0 && !empty($resolved->departure_date)) {
275 $dt = (string) $resolved->departure_date;
276 if (!empty($resolved->departure_time)) {
277 $dt .= ' ' . (string) $resolved->departure_time;
278 } else {
279 $dt .= ' 00:00';
280 }
281 $depTs = strtotime($dt);
282 if ($depTs !== false) {
283 $latest = $depTs - ($cutoffHours * 3600);
284 if (time() > $latest) {
285 return [
286 'success' => false,
287 'message' => __('Booking cutoff has passed for this departure.', 'yatra'),
288 ];
289 }
290 }
291 }
292
293 // Persist a snapshot of the resolved source for auditing and downstream modules.
294 $meta = [];
295 if (!empty($data['meta']) && is_string($data['meta'])) {
296 $decoded = json_decode($data['meta'], true);
297 $meta = is_array($decoded) ? $decoded : [];
298 } elseif (is_array($data['meta'] ?? null)) {
299 $meta = $data['meta'];
300 }
301 $meta['resolved_availability'] = [
302 'source' => $resolved->source ?? null,
303 'rule_id' => $resolved->rule_id ?? null,
304 'availability_id' => $resolved->id ?? null,
305 'departure_date' => $resolved->departure_date ?? null,
306 'departure_time' => $resolved->departure_time ?? null,
307 'pricing_type' => $resolved->pricing_type ?? null,
308 'price_types' => $resolved->price_types ?? [],
309 ];
310 $data['meta'] = wp_json_encode($meta);
311 } catch (\Throwable $e) {
312 // If resolution fails, allow booking to proceed using legacy validations.
313 }
314 }
315
316 Logger::info("After BookingValidator::sanitize", [
317 'data_keys' => array_keys($data),
318 'has_itinerary_costs' => isset($data['itinerary_costs']),
319 'has_itinerary_costs_total' => isset($data['itinerary_costs_total'])
320 ]);
321
322 // Business rule validations
323 $validationResult = $this->validateBookingBusinessRules($data);
324 if (!$validationResult['success']) {
325 Logger::warning("Booking business rule validation failed", [
326 'trip_id' => $data['trip_id'],
327 'reason' => $validationResult['message']
328 ]);
329 return $validationResult;
330 }
331
332 // Validate trip exists and is available
333 $trip = $this->tripRepository->find((int) $data['trip_id']);
334 if (!$trip) {
335 Logger::error("Trip not found for booking", ['trip_id' => $data['trip_id']]);
336 return ['success' => false, 'message' => __('Trip not found.', 'yatra')];
337 }
338
339 if ($trip->status !== 'publish') {
340 Logger::warning("Trip not available for booking", [
341 'trip_id' => $data['trip_id'],
342 'status' => $trip->status
343 ]);
344 return ['success' => false, 'message' => __('Trip is not available for booking.', 'yatra')];
345 }
346
347 // Calculate start_date and end_date if travel_date is provided
348 if (!empty($data['travel_date']) && empty($data['start_date'])) {
349 $data['start_date'] = $data['travel_date'];
350 }
351
352 if (!empty($data['start_date']) && empty($data['end_date'])) {
353 $data['end_date'] = $this->calculateEndDate($data['start_date'], (int) $data['trip_id']);
354 }
355
356 // Generate unique reference
357 $data['reference'] = $this->bookingRepository->generateReference();
358
359 // Find or create customer
360 if (!empty($data['contact_email'])) {
361 $customerId = $this->customerRepository->findOrCreate([
362 'email' => $data['contact_email'],
363 'first_name' => $data['contact_first_name'] ?? '',
364 'last_name' => $data['contact_last_name'] ?? '',
365 'phone' => $data['contact_phone'] ?? '',
366 'country' => $data['contact_country'] ?? '',
367 'user_id' => $data['user_id'] ?? null,
368 ]);
369 $data['customer_id'] = $customerId;
370 }
371
372 // Apply tax calculation to booking data
373 $data = BookingTaxService::applyTaxToBooking($data);
374
375 // Calculate amount due (recalculated after tax). Honor the selected
376 // payment method via Pro FlexiblePayments so a deposit/partial
377 // booking stores the reduced amount due now — not the full total.
378 // For 'full' (and when Pro is inactive) the filter returns
379 // total − paid unchanged, so full-payment bookings are unaffected.
380 // After the deposit is paid, payment completion resets amount_due to
381 // the remaining balance (total − amount_paid).
382 $bs_total = (float) ($data['total_amount'] ?? 0);
383 $bs_paid = (float) ($data['amount_paid'] ?? 0);
384 $bs_payment_method = strtolower(trim((string) ($data['payment_method'] ?? 'full')));
385 $bs_due_now = (float) apply_filters(
386 'yatra_calculate_amount_due',
387 $bs_total - $bs_paid,
388 $bs_total,
389 $bs_payment_method,
390 [
391 'trip_id' => (int) ($data['trip_id'] ?? 0),
392 // Tour start lets Pro enforce "pay in full when the tour is
393 // within the balance-due window" (tour-anchored payments).
394 'travel_date' => (string) ($data['travel_date'] ?? ($data['start_date'] ?? '')),
395 ]
396 );
397 $data['amount_due'] = max(0.0, round($bs_due_now, 2));
398
399 // Create booking
400 $bookingId = $this->bookingRepository->create($data);
401
402 if (!$bookingId) {
403 Logger::error("Failed to create booking in database", ['data' => $data]);
404 return ['success' => false, 'message' => __('Failed to create booking.', 'yatra')];
405 }
406
407 // Waitlist bookings do not consume departure capacity until promoted.
408 $isWaitlist = isset($data['status']) && $data['status'] === 'waitlist';
409
410 // Link booking to departure if start_date is provided
411 if (!$isWaitlist && !empty($data['start_date']) && !empty($data['end_date'])) {
412 try {
413 $trip = $this->tripRepository->find((int) $data['trip_id']);
414 // Get max capacity from trip's max_travelers, or use default
415 $maxCapacity = null;
416 if ($trip && !empty($trip->max_travelers)) {
417 $maxCapacity = (int) $trip->max_travelers;
418 }
419 $travelersCount = (int) ($data['travelers_count'] ?? 0);
420
421 $departureTime = null;
422 if (!empty($data['departure_time']) && is_string($data['departure_time'])) {
423 $departureTime = trim($data['departure_time']);
424 if ($departureTime === '') {
425 $departureTime = null;
426 }
427 }
428
429 // Find or create departure
430 $departure = $this->departureService->findOrCreateForBooking(
431 (int) $data['trip_id'],
432 $data['start_date'],
433 $data['end_date'],
434 $travelersCount,
435 $maxCapacity,
436 $departureTime
437 );
438
439 // Link booking to departure
440 $this->departureService->linkBookingToDeparture($bookingId, $departure->id);
441
442 // Increment booked count
443 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
444
445 Logger::info("Booking linked to departure", [
446 'booking_id' => $bookingId,
447 'departure_id' => $departure->id
448 ]);
449 } catch (\Exception $e) {
450 // Log error but don't fail the booking
451 Logger::warning("Failed to link booking to departure", [
452 'booking_id' => $bookingId,
453 'error' => $e->getMessage()
454 ]);
455 }
456 }
457
458 // Save travelers
459 if (!empty($data['travelers']) && is_array($data['travelers'])) {
460 $this->saveTravelers($bookingId, $data['travelers']);
461 }
462
463 // Customer confirmation: skip when checkout will send the session email (offline / zero due).
464 if (!$skipInitialCustomerConfirmation) {
465 $this->sendBookingConfirmationEmail($bookingId);
466 }
467
468 $executionTime = microtime(true) - $startTime;
469 Logger::info("Booking created successfully", [
470 'booking_id' => $bookingId,
471 'reference' => $data['reference'],
472 'execution_time' => $executionTime
473 ]);
474
475 $booking = $this->bookingRepository->find((int) $bookingId);
476 if (!is_object($booking)) {
477 $booking = (object) [];
478 }
479
480 // Defer the public booking-created action when the row is
481 // still in `pending_verification`. Sending the booking
482 // confirmation email and firing analytics integrations
483 // before the customer has proven the email is theirs would
484 // (a) leak the booking details to whoever owns that
485 // address, and (b) inflate conversion metrics with bookings
486 // that may never be verified. BookingSessionController::
487 // verify_email() re-fires this action after the status flip
488 // so every listener (NotificationHooks, EmailAutomation,
489 // analytics modules) still runs — just *after* verification.
490 //
491 // Inventory + cache invalidation aren't routed through this
492 // action (they're called directly above), so seat-holding
493 // continues to work while the customer is in the holding
494 // state.
495 $bookingStatus = (string) ($data['status'] ?? ($booking->status ?? ''));
496 if ($bookingStatus !== 'pending_verification') {
497 do_action(\Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED, (int) $bookingId, $booking);
498 }
499
500 return [
501 'success' => true,
502 'booking_id' => $bookingId,
503 'reference' => $data['reference'],
504 'message' => __('Booking created successfully.', 'yatra'),
505 ];
506
507 } catch (\Exception $e) {
508 $executionTime = microtime(true) - $startTime;
509 Logger::error("Booking creation failed", [
510 'trip_id' => $data['trip_id'] ?? null,
511 'execution_time' => $executionTime,
512 'error' => $e->getMessage()
513 ]);
514
515 return [
516 'success' => false,
517 'message' => $e->getMessage()
518 ];
519 }
520 }
521
522 /**
523 * Update a booking
524 *
525 * @param int $id Booking ID
526 * @param array $data Booking data
527 * @return array {success: bool, message: string}
528 */
529 public function updateBooking(int $id, array $data): array
530 {
531 $booking = $this->bookingRepository->find($id);
532
533 if (!$booking) {
534 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
535 }
536
537 // Reject an unknown payment status instead of handing it to MySQL. The
538 // column is an ENUM, so an unrecognised value was silently coerced —
539 // resetting a fully-paid booking to "pending" while amount_paid kept the
540 // money that had actually been received, and still returning success.
541 if (array_key_exists('payment_status', $data)) {
542 $paymentStatus = (string) $data['payment_status'];
543
544 if (!in_array($paymentStatus, self::PAYMENT_STATUSES, true)) {
545 return [
546 'success' => false,
547 'message' => sprintf(
548 /* translators: %s: the list of accepted payment statuses. */
549 __('Invalid payment status. Accepted values are: %s.', 'yatra'),
550 implode(', ', self::PAYMENT_STATUSES)
551 ),
552 ];
553 }
554 }
555
556 // Check if date is being changed
557 $oldStartDate = $booking->start_date ?? $booking->travel_date ?? null;
558 $newStartDate = $data['start_date'] ?? $data['travel_date'] ?? null;
559 $dateChanged = false;
560
561 if ($newStartDate && $oldStartDate && $newStartDate !== $oldStartDate) {
562 $dateChanged = true;
563 }
564
565 // Calculate end_date if start_date is provided
566 if (!empty($data['start_date']) && empty($data['end_date'])) {
567 $data['end_date'] = $this->calculateEndDate($data['start_date'], (int) $booking->trip_id);
568 } elseif (!empty($data['travel_date']) && empty($data['start_date']) && empty($data['end_date'])) {
569 $data['start_date'] = $data['travel_date'];
570 $data['end_date'] = $this->calculateEndDate($data['travel_date'], (int) $booking->trip_id);
571 }
572
573 $oldStatus = (string) ($booking->status ?? '');
574 $oldPaymentStatus = (string) ($booking->payment_status ?? '');
575 $oldTripId = (int) ($booking->trip_id ?? 0);
576
577 // Update booking
578 $updated = $this->bookingRepository->update($id, $data);
579
580 if (!$updated) {
581 return ['success' => false, 'message' => __('Failed to update booking.', 'yatra')];
582 }
583
584 $newStatus = isset($data['status']) ? (string) $data['status'] : null;
585 if ($newStatus !== null && $oldStatus === 'waitlist' && $newStatus !== 'waitlist') {
586 WaitlistService::releaseWaitlistHolding($booking);
587 }
588
589 // Re-link the departure when the date changed OR the operator picked a
590 // different departure time. A trip running several departures a day needs the
591 // time as well — moving a booking from the 09:00 to the 14:00 slot is not a
592 // date change, and without this it silently stayed on the original slot.
593 $departureTimeForUpdate = null;
594 if (!empty($data['departure_time']) && is_string($data['departure_time'])) {
595 $departureTimeForUpdate = trim($data['departure_time']) !== '' ? trim($data['departure_time']) : null;
596 }
597
598 // Changing the tour (trip_id) also has to move the departure: the booking
599 // now belongs to a different trip, so its seat has to be released from the
600 // old trip's departure and taken on the new trip's departure. Previously
601 // only a date/time change triggered the re-link, so switching Tour A -> Tour B
602 // left the booking counted against Tour A's departure (and absent from
603 // Tour B's) — over-selling A and under-selling B.
604 $tripChanged = isset($data['trip_id'])
605 && (int) $data['trip_id'] > 0
606 && (int) $data['trip_id'] !== $oldTripId;
607
608 // handleBookingDateChange() re-reads the booking (already saved with the new
609 // trip_id above) and needs the effective travel dates. When only the tour
610 // changed, the date fields aren't in $data, so fall back to the booking's
611 // current dates rather than skipping the re-link.
612 $effectiveStart = !empty($data['start_date'])
613 ? $data['start_date']
614 : ($booking->start_date ?? $booking->travel_date ?? null);
615 $effectiveEnd = !empty($data['end_date'])
616 ? $data['end_date']
617 : ($booking->end_date ?? $effectiveStart);
618
619 if (($dateChanged || $tripChanged || $departureTimeForUpdate !== null)
620 && !empty($effectiveStart) && !empty($effectiveEnd)) {
621 try {
622 $this->departureService->handleBookingDateChange(
623 $id,
624 $effectiveStart,
625 $effectiveEnd,
626 $departureTimeForUpdate
627 );
628 } catch (\Exception $e) {
629 // Log error but don't fail the update
630 }
631 }
632
633 // Update travelers if provided
634 if (isset($data['travelers']) && is_array($data['travelers'])) {
635 // Delete existing travelers
636 $this->travellerRepository->deleteByBookingId($id);
637 // Save new travelers
638 $this->saveTravelers($id, $data['travelers']);
639 }
640
641 // A manual payment-status change (e.g. an admin marking an offline
642 // bank-transfer booking as Paid) fired no notification and no hook before,
643 // so the customer was never told their payment was received. Detect the
644 // change and notify — without firing `yatra_payment_completed` (that means
645 // a real gateway charge and carries capture side effects).
646 $newPaymentStatus = isset($data['payment_status']) ? (string) $data['payment_status'] : null;
647 if ($newPaymentStatus !== null && $newPaymentStatus !== $oldPaymentStatus) {
648 $this->handlePaymentStatusChange($id, $oldPaymentStatus, $newPaymentStatus);
649 }
650
651 // Changing the status here fired no event at all, so confirming a booking
652 // from the edit form saved the status and then went silent: no confirmation
653 // email, no Email Automation sequence (booking.confirmed / .cancelled /
654 // .completed), no seat release on cancel. Only the status action
655 // (updateStatus) ever emitted it, which is why the same change appeared to
656 // work from one screen and not the other.
657 //
658 // Fired last, once the travellers and related rows are saved, so listeners
659 // read the booking's final state — and only on a real transition, so
660 // re-saving the form without touching the status stays silent.
661 if ($newStatus !== null && $newStatus !== $oldStatus) {
662 // Same order as updateStatus(): the notification is sent inline (it is
663 // not a listener on the action below), then the event fans out.
664 $this->sendStatusChangeNotification($id, $oldStatus, $newStatus);
665
666 /**
667 * Fires when a booking's status changes.
668 *
669 * @param int $id The booking ID
670 * @param string $oldStatus Previous status
671 * @param string $newStatus New status
672 */
673 do_action('yatra_booking_status_changed', $id, $oldStatus, $newStatus);
674
675 if ($newStatus === 'confirmed' && $oldStatus !== 'confirmed'
676 && function_exists('yatra_trigger_booking_confirmed')) {
677 yatra_trigger_booking_confirmed($id, $oldStatus);
678 }
679
680 if ($newStatus === 'cancelled' && $oldStatus !== 'cancelled'
681 && function_exists('yatra_trigger_booking_cancelled')) {
682 yatra_trigger_booking_cancelled($id, $oldStatus);
683 }
684 }
685
686 // Return the fresh booking so the REST controller's `$result['data']`
687 // is defined (previously absent → "Undefined array key data" warning).
688 return [
689 'success' => true,
690 'message' => __('Booking updated successfully.', 'yatra'),
691 'data' => $this->bookingRepository->find($id),
692 ];
693 }
694
695 /**
696 * Record an operator-confirmed payment against a booking.
697 *
698 * Used when a booking is marked paid by hand — typically an offline payment
699 * such as a bank transfer or cash, where no gateway callback ever arrives.
700 * Without this the booking claimed the money while the ledger showed
701 * nothing, and the Payments screen stayed empty.
702 *
703 * Written as `completed` because the operator is asserting the funds were
704 * received; `payment_type` reflects whether this settles a balance or is the
705 * only payment on the booking.
706 */
707 private function recordManualPayment(object $booking, int $bookingId, float $amount, float $existingLedger): void
708 {
709 $gateway = (string) ($booking->payment_gateway ?? $booking->payment_method ?? '');
710
711 if (trim($gateway) === '') {
712 // `gateway` is NOT NULL on the payments table.
713 $gateway = 'manual';
714 }
715
716 $this->paymentRepository->create([
717 'booking_id' => $bookingId,
718 'customer_id' => !empty($booking->customer_id) ? (int) $booking->customer_id : null,
719 'gateway' => $gateway,
720 'amount' => $amount,
721 'currency' => (string) ($booking->currency ?? SettingsService::getCurrency()),
722 'status' => 'completed',
723 'payment_type' => $existingLedger > 0 ? 'final' : 'initial',
724 'notes' => __('Recorded manually when the booking was marked as paid.', 'yatra'),
725 'processed_at' => current_time('mysql'),
726 'created_at' => current_time('mysql'),
727 ]);
728
729 do_action('yatra_manual_payment_recorded', $bookingId, $amount, $gateway);
730 }
731
732 /**
733 * React to a manual payment-status change (admin edits, e.g. bank transfer
734 * marked Paid). Sends the customer + admin payment emails when money is
735 * (fully or partially) received, and fires `yatra_payment_status_changed`
736 * so integrations can react. Intentionally separate from
737 * `yatra_payment_completed`, which represents a real gateway capture.
738 */
739 private function handlePaymentStatusChange(int $bookingId, string $oldStatus, string $newStatus): void
740 {
741 $booking = $this->bookingRepository->findWithTrip($bookingId);
742 if (!$booking) {
743 return;
744 }
745
746 do_action('yatra_payment_status_changed', $bookingId, $oldStatus, $newStatus, $booking);
747
748 // Marking a booking paid has to settle its money fields too. An operator
749 // confirming an offline payment (bank transfer, cash) has no payment row
750 // to mark as completed — this status change is the only signal we get.
751 // Without reconciling here the booking read "paid" while amount_paid
752 // stayed 0 and amount_due kept the outstanding figure, so the invoice
753 // still reported "Payment Pending" with nothing paid and the full amount
754 // due.
755 //
756 // Only ever settles UP: a recorded amount_paid at or above the total is
757 // left alone, so this can never erase or reduce a real payment. The other
758 // statuses are deliberately untouched — "partial" carries no amount to
759 // apply, and zeroing on "pending"/"refunded" would destroy payment data.
760 if ($newStatus === 'paid') {
761 $total = (float) ($booking->total_amount ?? 0);
762 $recorded = (float) ($booking->amount_paid ?? 0);
763
764 if ($total > 0) {
765 // The payments ledger is the source of truth: PaymentService
766 // recalculates amount_paid from it whenever a payment is added,
767 // so a booking marked paid without a matching ledger row would
768 // silently revert to "partial" the next time any payment was
769 // recorded. Write the outstanding balance as a real payment so
770 // the two agree and the Payments screen shows what was received.
771 $ledger = (float) $this->paymentRepository->getTotalPaidForBooking($bookingId);
772
773 // Measure the gap against whichever figure is higher so an
774 // existing (pre-ledger) amount_paid is never double-counted.
775 $alreadyCovered = max($ledger, $recorded);
776 $outstanding = round($total - $alreadyCovered, 2);
777
778 if ($outstanding > 0) {
779 $this->recordManualPayment($booking, $bookingId, $outstanding, $ledger);
780 $ledger = (float) $this->paymentRepository->getTotalPaidForBooking($bookingId);
781 }
782
783 // Never reduce a recorded overpayment: settle up, never down.
784 $newAmountPaid = max($ledger, $recorded);
785
786 if ($newAmountPaid > $recorded || $recorded < $total) {
787 // Canonical writer — also derives amount_due and keeps
788 // payment_status consistent with the amounts.
789 $this->bookingRepository->updateAmountPaid($bookingId, $newAmountPaid);
790
791 $booking->amount_paid = $newAmountPaid;
792 $booking->amount_due = max(0.0, $total - $newAmountPaid);
793 }
794 }
795 }
796
797 if (in_array($newStatus, ['paid', 'partial'], true)) {
798 $paidAmount = (float) ($booking->amount_paid ?? 0);
799 if ($paidAmount <= 0) {
800 $paidAmount = (float) ($booking->total_amount ?? 0);
801 }
802 \Yatra\Services\NotificationService::sendPaymentCompletedNotification([
803 'booking_id' => $bookingId,
804 'amount' => $paidAmount,
805 'payment_method' => (string) ($booking->payment_method ?? ''),
806 'transaction_id' => '',
807 ]);
808 }
809 }
810
811 /**
812 * Calculate end date from start date and trip duration
813 *
814 * @param string $startDate Start date (YYYY-MM-DD)
815 * @param int $tripId Trip ID
816 * @return string End date (YYYY-MM-DD)
817 */
818 private function calculateEndDate(string $startDate, int $tripId): string
819 {
820 return $this->bookingRepository->calculateEndDate($startDate, $tripId);
821 }
822
823 /**
824 * Update booking status
825 *
826 * @param int $id Booking ID
827 * @param string $status New status
828 * @return array {success: bool, message: string}
829 */
830 public function updateStatus(int $id, string $status): array
831 {
832 $validStatuses = ['pending', 'confirmed', 'processing', 'completed', 'cancelled', 'refunded', 'failed', 'on_hold'];
833
834 if (!in_array($status, $validStatuses, true)) {
835 return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
836 }
837
838 $booking = $this->bookingRepository->find($id);
839
840 if (!$booking) {
841 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
842 }
843
844 $oldStatus = $booking->status;
845 $updated = $this->bookingRepository->updateStatus($id, $status);
846
847 if (!$updated) {
848 return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
849 }
850
851 if ($oldStatus === 'waitlist' && $status !== 'waitlist') {
852 WaitlistService::releaseWaitlistHolding($booking);
853 }
854
855 // ========================================
856 // HANDLE DEPARTURE BOOKED_COUNT UPDATE
857 // ========================================
858 // If booking is cancelled or refunded, unlink from departure and decrement booked_count
859 // If booking status changes from cancelled/refunded to active, link and increment booked_count
860 try {
861 $departure = $this->departureService->getDepartureForBooking($id);
862 $travelersCount = (int) ($booking->travelers_count ?? 0);
863
864 if ($departure) {
865 // If booking is being cancelled or refunded
866 if (in_array($status, ['cancelled', 'refunded'], true) &&
867 !in_array($oldStatus, ['cancelled', 'refunded'], true)) {
868 // Unlink booking from departure (this will handle cancellation if no bookings remain)
869 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
870 }
871 // If booking status changes from cancelled/refunded back to active
872 elseif (in_array($oldStatus, ['cancelled', 'refunded'], true) &&
873 !in_array($status, ['cancelled', 'refunded'], true)) {
874 // Ensure booking is linked and increment booked count
875 $this->departureService->linkBookingToDeparture($id, $departure->id);
876 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
877 }
878 } elseif (!empty($booking->start_date) || !empty($booking->travel_date)) {
879 // Booking doesn't have a departure yet, but has a date - create and link
880 $startDate = $booking->start_date ?? $booking->travel_date;
881 $endDate = $booking->end_date ?? $this->calculateEndDate($startDate, (int) $booking->trip_id);
882
883 $trip = $this->tripRepository->find((int) $booking->trip_id);
884 // Resolve capacity from the trip's real column (`max_travelers`).
885 // The old `$trip->max_capacity` does not exist on the trips table,
886 // so this always fell back to 9999 — seeding a junk "unlimited"
887 // sentinel that then showed as a huge number on the Departures page
888 // but as 0 on the dashboard. Pass null when unset so
889 // findOrCreateForBooking resolves via Availability, then its own
890 // trip-default fallback, exactly like the primary creation path.
891 $maxCapacity = ($trip && !empty($trip->max_travelers)) ? (int) $trip->max_travelers : null;
892
893 $departure = $this->departureService->findOrCreateForBooking(
894 (int) $booking->trip_id,
895 $startDate,
896 $endDate,
897 $travelersCount,
898 $maxCapacity
899 );
900
901 $this->departureService->linkBookingToDeparture($id, $departure->id);
902 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
903 }
904 } catch (\Exception $e) {
905 // Log error but don't fail the status update
906 }
907
908 // Send status change notification
909 $this->sendStatusChangeNotification($id, $oldStatus, $status);
910
911 /**
912 * Action: Booking status changed
913 * Fires when booking status changes
914 *
915 * @param int $id The booking ID
916 * @param string $oldStatus Previous status
917 * @param string $status New status
918 * @since 3.0.0
919 */
920 do_action('yatra_booking_status_changed', $id, $oldStatus, $status);
921
922 if ($status === 'confirmed' && $oldStatus !== 'confirmed') {
923 \yatra_trigger_booking_confirmed($id, $oldStatus);
924 }
925
926 if ($status === 'cancelled' && $oldStatus !== 'cancelled') {
927 \yatra_trigger_booking_cancelled($id, $oldStatus);
928 }
929
930 return [
931 'success' => true,
932 'message' => sprintf(
933 /* translators: %s: new booking status. */
934 __('Booking status updated to %s.', 'yatra'),
935 $status
936 ),
937 ];
938 }
939
940 /**
941 * Delete a booking
942 *
943 * @param int $id Booking ID
944 * @return array {success: bool, message: string}
945 */
946 public function deleteBooking(int $id): array
947 {
948 $booking = $this->bookingRepository->find($id);
949
950 if (!$booking) {
951 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
952 }
953
954 if (($booking->status ?? '') === 'waitlist') {
955 WaitlistService::releaseWaitlistHolding($booking);
956 }
957
958 try {
959 $departure = $this->departureService->getDepartureForBooking($id);
960 if ($departure) {
961 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
962 }
963 } catch (\Throwable $e) {
964 // Continue with delete
965 }
966
967 // Delete related travelers
968 $this->travellerRepository->deleteByBookingId($id);
969
970 // Delete booking
971 $deleted = $this->bookingRepository->delete($id);
972
973 if (!$deleted) {
974 return ['success' => false, 'message' => __('Failed to delete booking.', 'yatra')];
975 }
976
977 if (!is_object($booking)) {
978 $booking = (object) [];
979 }
980
981 do_action('yatra_booking_deleted', (int) $id, $booking);
982
983 return [
984 'success' => true,
985 'message' => __('Booking deleted successfully.', 'yatra'),
986 ];
987 }
988
989 /**
990 * Get booking statistics
991 *
992 * @return array
993 */
994 public function getStats(): array
995 {
996 return $this->bookingRepository->getStats();
997 }
998
999 /**
1000 * Get booking payments
1001 *
1002 * @param int $bookingId Booking ID
1003 * @return array
1004 */
1005 public function getBookingPayments(int $bookingId): array
1006 {
1007 return $this->paymentRepository->findByBookingId($bookingId);
1008 }
1009
1010 /**
1011 * Get booking travelers
1012 *
1013 * @param int $bookingId Booking ID
1014 * @return array
1015 */
1016 public function getBookingTravelers(int $bookingId): array
1017 {
1018 return $this->travellerRepository->getByBookingId($bookingId);
1019 }
1020
1021 /**
1022 * Format booking for API response
1023 *
1024 * @param object $booking Raw booking data
1025 * @return array
1026 */
1027 private function formatBooking(object $booking): array
1028 {
1029 // Build customer name from contact fields
1030 $customerName = trim(
1031 ($booking->customer_first_name ?? $booking->contact_first_name ?? '') . ' ' . ($booking->customer_last_name ?? $booking->contact_last_name ?? '')
1032 ) ?: ($booking->customer_name ?? $booking->customer_email ?? $booking->contact_email ?? null);
1033
1034 $customerEmail = $booking->customer_email ?? $booking->contact_email ?? null;
1035 $customerPhone = $booking->contact_phone ?? null;
1036
1037 // Fallback: fetch customer record if customer_id is set and info missing
1038 if (($booking->customer_id ?? 0) && (empty($customerName) || empty($customerEmail) || empty($customerPhone))) {
1039 $customerRepo = new \Yatra\Repositories\CustomerRepository();
1040 $customerRecord = $customerRepo->find((int)$booking->customer_id);
1041 if ($customerRecord) {
1042 if (empty($customerName)) {
1043 $customerName = trim(($customerRecord->first_name ?? '') . ' ' . ($customerRecord->last_name ?? '')) ?: ($customerRecord->email ?? $customerName);
1044 }
1045 if (empty($customerEmail)) {
1046 $customerEmail = $customerRecord->email ?? $customerEmail;
1047 }
1048 if (empty($customerPhone)) {
1049 $customerPhone = $customerRecord->phone ?? $customerPhone;
1050 }
1051 if (empty($booking->contact_first_name) && !empty($customerRecord->first_name)) {
1052 $booking->contact_first_name = $customerRecord->first_name;
1053 }
1054 if (empty($booking->contact_last_name) && !empty($customerRecord->last_name)) {
1055 $booking->contact_last_name = $customerRecord->last_name;
1056 }
1057 if (empty($booking->contact_country) && !empty($customerRecord->country)) {
1058 $booking->contact_country = $customerRecord->country;
1059 }
1060 }
1061 }
1062
1063 return [
1064 'id' => (int) $booking->id,
1065 'reference' => $booking->reference,
1066 // UI expects booking_number and booking_status fields
1067 'booking_number' => $booking->reference,
1068 'booking_status' => $booking->status,
1069 'trip_id' => (int) $booking->trip_id,
1070 'trip_title' => $booking->trip_title ?? '',
1071 'trip_slug' => $booking->trip_slug ?? '',
1072 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
1073 'user_id' => $booking->user_id ? (int) $booking->user_id : null,
1074 'customer_name' => $customerName,
1075 'customer_email' => $customerEmail,
1076 'customer_phone' => $customerPhone,
1077 'contact' => [
1078 'first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
1079 'last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
1080 'email' => $customerEmail,
1081 'phone' => $customerPhone,
1082 'country' => $booking->contact_country,
1083 ],
1084 'contact_first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
1085 'contact_last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
1086 'contact_email' => $customerEmail,
1087 'contact_phone' => $customerPhone,
1088 'contact_country' => $booking->contact_country ?? null,
1089 'travel_date' => $booking->travel_date,
1090 'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
1091 'end_date' => $booking->end_date ?? null,
1092 // travelers_count stored; also fallback to total_travelers/travelers if present
1093 'travelers_count' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
1094 'travelers' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
1095 'total_amount' => (float) $booking->total_amount,
1096 'amount_paid' => (float) $booking->amount_paid,
1097 'amount_due' => (float) $booking->amount_due,
1098 'discount_amount' => (float) ($booking->discount_amount ?? 0),
1099 'discount_code' => $booking->discount_code ?? null,
1100 'currency' => $booking->currency,
1101 'tax_amount' => (float) ($booking->tax_amount ?? 0),
1102 'tax_rate' => (float) ($booking->tax_rate ?? 0),
1103 'tax_inclusive' => (int) ($booking->tax_inclusive ?? 0),
1104 'tax_details' => $booking->tax_details ?? null,
1105 'tax_breakdown' => $booking->tax_details ? json_decode($booking->tax_details, true) : [],
1106 'subtotal' => (float) ($booking->subtotal ?? $booking->total_amount ?? 0),
1107 'taxable_amount' => (float) (($booking->subtotal ?? $booking->total_amount ?? 0) + ($booking->itinerary_costs_total ?? 0)),
1108 'itinerary_costs' => $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [],
1109 'itinerary_costs_total' => (float) ($booking->itinerary_costs_total ?? 0),
1110 'status' => $booking->status,
1111 'payment_status' => $booking->payment_status,
1112 // Some UIs expect payment_method; map from payment_gateway
1113 'payment_gateway' => $booking->payment_gateway,
1114 'payment_method' => $booking->payment_gateway,
1115 // booking_date is used in admin table; map to created_at
1116 'booking_date' => $booking->created_at,
1117 'created_at' => $booking->created_at,
1118 'updated_at' => $booking->updated_at,
1119 ];
1120 }
1121
1122 /**
1123 * Format booking with all details for single view
1124 *
1125 * @param object $booking Raw booking data
1126 * @return array
1127 */
1128 private function formatBookingWithDetails(object $booking): array
1129 {
1130 $formatted = $this->formatBooking($booking);
1131
1132 // Add customer name for easier access
1133 $formatted['customer_name'] = trim(
1134 ($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? '')
1135 ) ?: null;
1136 $formatted['customer_email'] = $booking->contact_email ?? null;
1137 $formatted['customer_phone'] = $booking->contact_phone ?? null;
1138
1139 // Also add contact fields at root level for backward compatibility
1140 $formatted['contact_first_name'] = $booking->contact_first_name ?? null;
1141 $formatted['contact_last_name'] = $booking->contact_last_name ?? null;
1142 $formatted['contact_email'] = $booking->contact_email ?? null;
1143 $formatted['contact_phone'] = $booking->contact_phone ?? null;
1144 $formatted['contact_country'] = $booking->contact_country ?? null;
1145
1146 // Add full contact data
1147 $formatted['contact_data'] = $booking->contact_data ? json_decode($booking->contact_data, true) : null;
1148
1149 // Add emergency contact: handle JSON, serialized, or array
1150 $emergency = $booking->emergency_contact ?? null;
1151 if (is_string($emergency)) {
1152 $decoded = json_decode($emergency, true);
1153 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
1154 $emergency = $decoded;
1155 } else {
1156 $maybe = maybe_unserialize($emergency);
1157 $emergency = is_array($maybe) ? $maybe : null;
1158 }
1159 } elseif (!is_array($emergency)) {
1160 $emergency = null;
1161 }
1162 $formatted['emergency_contact'] = $emergency;
1163
1164 // Add travelers
1165 $formatted['travelers'] = $this->getBookingTravelers((int) $booking->id);
1166
1167 // Add payments
1168 $formatted['payments'] = $this->getBookingPayments((int) $booking->id);
1169
1170 // Add tax breakdown
1171 $formatted['tax_breakdown'] = BookingTaxService::getBookingTaxBreakdown((array) $formatted);
1172 $formatted['tax_display'] = BookingTaxService::formatBookingTaxDisplay((array) $formatted);
1173
1174 // Add itinerary costs
1175 $formatted['itinerary_costs'] = $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [];
1176 $formatted['itinerary_costs_total'] = (float) ($booking->itinerary_costs_total ?? 0);
1177
1178 // Add additional fields
1179 $formatted['special_requests'] = $booking->special_requests;
1180 $formatted['internal_notes'] = $booking->internal_notes;
1181 $formatted['payment_transaction_id'] = $booking->payment_transaction_id;
1182 $formatted['cancelled_at'] = $booking->cancelled_at;
1183 $formatted['cancellation_reason'] = $booking->cancellation_reason;
1184 $formatted['confirmed_at'] = $booking->confirmed_at;
1185 $formatted['completed_at'] = $booking->completed_at;
1186
1187 /**
1188 * Filter: Add additional services to booking details
1189 * Allows premium modules to include services data in booking response
1190 *
1191 * @param array $services Empty array by default
1192 * @param int $booking_id The booking ID
1193 * @since 3.0.0
1194 */
1195 $formatted['additional_services'] = apply_filters('yatra_booking_get_services', [], (int) $booking->id);
1196
1197 $formatted = apply_filters('yatra_booking_details', $formatted, (int) $booking->id);
1198
1199 return $formatted;
1200 }
1201
1202 /**
1203 * Save travelers for a booking
1204 *
1205 * @param int $bookingId Booking ID
1206 * @param array $travelers Travelers data
1207 */
1208 private function saveTravelers(int $bookingId, array $travelers): void
1209 {
1210 // Re-index defensively so traveller_index / is_lead are positional and
1211 // contiguous regardless of the incoming keys.
1212 $index = 0;
1213 foreach ($travelers as $travelerData) {
1214 if (!is_array($travelerData)) {
1215 continue;
1216 }
1217 $isLead = $index === 0;
1218 // Accept both shapes: a nested { fields: {...} } (repository format)
1219 // or a flat field map (admin BookingForm). Drop non-field meta keys.
1220 $fields = isset($travelerData['fields']) && is_array($travelerData['fields'])
1221 ? $travelerData['fields']
1222 : $travelerData;
1223 unset($fields['is_lead'], $fields['traveller_index'], $fields['id'], $fields['booking_id']);
1224 // create() is the real repository method (createTraveller() never existed);
1225 // it inserts the traveller row and writes every field to the meta table —
1226 // the same method the checkout flow uses.
1227 $this->travellerRepository->create($bookingId, $index, $isLead, $fields);
1228 $index++;
1229 }
1230 }
1231
1232 /**
1233 * Transactional "new booking" email (settings / Pro templates). Used by checkout when the session
1234 * defers email until after payment redirect or sends the rich HTML confirmation at the end.
1235 */
1236 public function sendNewBookingTransactionalConfirmation(int $bookingId): void
1237 {
1238 $this->sendBookingConfirmationEmail($bookingId);
1239 }
1240
1241 /**
1242 * Send booking confirmation email
1243 *
1244 * @param int $bookingId Booking ID
1245 */
1246 private function sendBookingConfirmationEmail(int $bookingId): void
1247 {
1248 $booking = $this->bookingRepository->findWithTrip($bookingId);
1249
1250 if (!$booking || empty($booking->contact_email)) {
1251 return;
1252 }
1253
1254 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1255 $vars['intro_paragraph'] = __('Thank you for your booking! Here are your details:', 'yatra');
1256 $vars['transactional_context'] = 'booking_created';
1257
1258 TransactionalEmailTemplateService::sendIfEnabled(
1259 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1260 $booking->contact_email,
1261 $vars
1262 );
1263 }
1264
1265 /**
1266 * Send the "your booking has been confirmed" transactional email.
1267 *
1268 * Shared by the manual / programmatic status-change path ({@see updateStatus()}
1269 * → sendStatusChangeNotification()) and the asynchronous payment-completion
1270 * paths (via {@see yatra_trigger_booking_confirmed()}), which set a booking to
1271 * `confirmed` with a direct DB write and never pass through updateStatus().
1272 * Gated by the template's enabled flag (`sendIfEnabled`).
1273 *
1274 * @param int $bookingId Booking ID.
1275 */
1276 public function sendBookingConfirmedEmail(int $bookingId): void
1277 {
1278 $booking = $this->bookingRepository->findWithTrip($bookingId);
1279
1280 if (!$booking || empty($booking->contact_email)) {
1281 return;
1282 }
1283
1284 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1285 $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra');
1286 $vars['transactional_context'] = 'status_confirmed';
1287 TransactionalEmailTemplateService::sendIfEnabled(
1288 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1289 $booking->contact_email,
1290 $vars
1291 );
1292 }
1293
1294 /**
1295 * Send status change notification
1296 *
1297 * @param int $bookingId Booking ID
1298 * @param string $oldStatus Previous status
1299 * @param string $newStatus New status
1300 */
1301 private function sendStatusChangeNotification(int $bookingId, string $oldStatus, string $newStatus): void
1302 {
1303 // Only send for certain status changes
1304 $notifyStatuses = ['confirmed', 'cancelled', 'completed'];
1305
1306 if (!in_array($newStatus, $notifyStatuses, true)) {
1307 return;
1308 }
1309
1310 $booking = $this->bookingRepository->findWithTrip($bookingId);
1311
1312 if (!$booking || empty($booking->contact_email)) {
1313 return;
1314 }
1315
1316 if ($newStatus === 'cancelled') {
1317 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1318 $vars['cancellation_reason'] = (string) ($booking->cancellation_reason ?? '');
1319 TransactionalEmailTemplateService::sendIfEnabled(
1320 TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
1321 $booking->contact_email,
1322 $vars
1323 );
1324
1325 return;
1326 }
1327
1328 if ($newStatus === 'confirmed') {
1329 $this->sendBookingConfirmedEmail($bookingId);
1330
1331 return;
1332 }
1333
1334 if ($newStatus === 'completed') {
1335 $handled = apply_filters('yatra_send_booking_status_email_html', null, $bookingId, $oldStatus, $newStatus, $booking);
1336 if ($handled !== null) {
1337 ReviewReminderService::scheduleReminder($bookingId);
1338
1339 return;
1340 }
1341
1342 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1343 $vars['completion_date'] = date_i18n(get_option('date_format'));
1344 TransactionalEmailTemplateService::sendIfEnabled(
1345 TransactionalEmailTemplateService::TYPE_BOOKING_COMPLETED,
1346 $booking->contact_email,
1347 $vars
1348 );
1349
1350 ReviewReminderService::scheduleReminder($bookingId);
1351
1352 return;
1353 }
1354 }
1355
1356 /**
1357 * Get all travelers with pagination
1358 *
1359 * @param array $filters Filters
1360 * @return array
1361 */
1362 public function getTravelers(array $filters = []): array
1363 {
1364 return $this->travellerRepository->paginate($filters);
1365 }
1366
1367 /**
1368 * Perform bulk actions on travelers
1369 *
1370 * Currently supports only delete.
1371 *
1372 * @param int[] $ids Traveler IDs
1373 * @param string $action Action key (e.g. 'delete')
1374 * @return array {success: bool, message: string}
1375 */
1376 public function bulkTravelers(array $ids, string $action): array
1377 {
1378 $action = trim($action);
1379
1380 if ($action !== 'delete') {
1381 return [
1382 'success' => false,
1383 'message' => __('Invalid traveler bulk action.', 'yatra'),
1384 ];
1385 }
1386
1387 return $this->travellerRepository->bulkDelete($ids);
1388 }
1389
1390 /**
1391 * Send booking email
1392 *
1393 * @param int $bookingId Booking ID
1394 * @param string $emailType Email type (confirmation, reminder, etc.)
1395 * @return array {success: bool, message: string}
1396 */
1397 public function sendEmail(int $bookingId, string $emailType = 'confirmation'): array
1398 {
1399 $booking = $this->bookingRepository->findWithTrip($bookingId);
1400
1401 if (!$booking) {
1402 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
1403 }
1404
1405 // Customer-facing emails need a recipient; admin notifications go to the
1406 // store admin address, so they don't require the booking's contact email.
1407 $customerEmail = (string) ($booking->contact_email ?? '');
1408 $customerTypes = ['confirmation', 'reminder', 'cancellation', 'completed', 'payment_confirmation'];
1409 if (in_array($emailType, $customerTypes, true) && $customerEmail === '') {
1410 return ['success' => false, 'message' => __('No customer email address on this booking.', 'yatra')];
1411 }
1412
1413 switch ($emailType) {
1414 case 'confirmation':
1415 // Resend the email that matches the booking's CURRENT state so it
1416 // is identical to the automated one. A confirmed booking (or a
1417 // completed one — it was confirmed before it travelled) gets the
1418 // "booking confirmed" email (status_confirmed context → the
1419 // `booking_confirmed` / booking.confirmed template); anything
1420 // still pending gets the initial "booking received" email
1421 // (booking_created context → the `booking_confirmation` template).
1422 if (in_array((string) ($booking->status ?? ''), ['confirmed', 'completed'], true)) {
1423 $this->sendBookingConfirmedEmail($bookingId);
1424 } else {
1425 $this->sendBookingConfirmationEmail($bookingId);
1426 }
1427 break;
1428
1429 case 'reminder':
1430 $this->sendBookingReminderEmail($booking);
1431 break;
1432
1433 case 'cancellation':
1434 // Mirrors sendStatusChangeNotification() so the resent email is
1435 // identical to the automated cancellation email.
1436 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1437 $vars['cancellation_reason'] = (string) ($booking->cancellation_reason ?? '');
1438 TransactionalEmailTemplateService::sendIfEnabled(
1439 TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
1440 $customerEmail,
1441 $vars
1442 );
1443 break;
1444
1445 case 'completed':
1446 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1447 $vars['completion_date'] = date_i18n(get_option('date_format'));
1448 TransactionalEmailTemplateService::sendIfEnabled(
1449 TransactionalEmailTemplateService::TYPE_BOOKING_COMPLETED,
1450 $customerEmail,
1451 $vars
1452 );
1453 break;
1454
1455 case 'payment_confirmation':
1456 $paymentData = $this->buildPaymentDataForResend($booking);
1457 if ($paymentData === null) {
1458 return ['success' => false, 'message' => __('No recorded payment to resend for this booking.', 'yatra')];
1459 }
1460 \Yatra\Services\NotificationService::resendCustomerPaymentEmail($paymentData);
1461 break;
1462
1463 case 'admin_new_booking':
1464 \Yatra\Services\NotificationService::sendBookingCreatedNotification($bookingId, (array) $booking);
1465 break;
1466
1467 case 'admin_payment_received':
1468 $paymentData = $this->buildPaymentDataForResend($booking);
1469 if ($paymentData === null) {
1470 return ['success' => false, 'message' => __('No recorded payment to resend for this booking.', 'yatra')];
1471 }
1472 \Yatra\Services\NotificationService::resendAdminPaymentEmail($paymentData);
1473 break;
1474
1475 default:
1476 return ['success' => false, 'message' => __('Unknown email type.', 'yatra')];
1477 }
1478
1479 return [
1480 'success' => true,
1481 'message' => __('Email sent successfully.', 'yatra'),
1482 ];
1483 }
1484
1485 /**
1486 * Reconstruct the payment-notification payload for a resend from the latest
1487 * payment on the booking (falling back to the booking's own amount_paid /
1488 * gateway when no ledger row exists). Returns null when nothing has been
1489 * paid, so there is no payment to acknowledge.
1490 */
1491 private function buildPaymentDataForResend(object $booking): ?array
1492 {
1493 $bookingId = (int) ($booking->id ?? 0);
1494 $payment = $this->paymentRepository->findLatestByBookingId($bookingId);
1495
1496 $amount = (float) ($payment->amount ?? $booking->amount_paid ?? 0);
1497 if ($amount <= 0) {
1498 return null;
1499 }
1500
1501 return [
1502 'booking_id' => $bookingId,
1503 'amount' => $amount,
1504 'payment_method' => (string) ($payment->gateway ?? $booking->payment_gateway ?? ''),
1505 'transaction_id' => (string) ($payment->transaction_id ?? ''),
1506 ];
1507 }
1508
1509 /**
1510 * Send booking reminder email
1511 *
1512 * @param object $booking Booking data
1513 */
1514 private function sendBookingReminderEmail(object $booking): void
1515 {
1516 $daysUntilTrip = (int) ((strtotime((string) $booking->travel_date) - time()) / 86400);
1517 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1518 $vars['days_until_trip'] = (string) max(0, $daysUntilTrip);
1519 $vars['reminder_days'] = (string) SettingsService::getInt('booking_reminder_days', 3);
1520
1521 $checklist = '<p><strong>' . esc_html__('Preparation checklist', 'yatra') . '</strong></p><ul>'
1522 . '<li>' . esc_html__('Valid government-issued ID', 'yatra') . '</li>'
1523 . '<li>' . esc_html__('Travel insurance', 'yatra') . '</li>'
1524 . '<li>' . esc_html__('Emergency contacts', 'yatra') . '</li>'
1525 . '</ul>';
1526 $vars['reminder_extra_html'] = $checklist;
1527
1528 $sent = TransactionalEmailTemplateService::sendIfEnabled(
1529 TransactionalEmailTemplateService::TYPE_BOOKING_REMINDER,
1530 $booking->contact_email,
1531 $vars
1532 );
1533
1534 if ($sent) {
1535 $this->bookingRepository->update((int) $booking->id, [
1536 'reminder_sent' => 1,
1537 'reminder_sent_at' => current_time('mysql'),
1538 ]);
1539 }
1540 }
1541 }
1542
1543