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

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

1,501 lines 63.7 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
681 // Return the fresh booking so the REST controller's `$result['data']`
682 // is defined (previously absent → "Undefined array key data" warning).
683 return [
684 'success' => true,
685 'message' => __('Booking updated successfully.', 'yatra'),
686 'data' => $this->bookingRepository->find($id),
687 ];
688 }
689
690 /**
691 * Record an operator-confirmed payment against a booking.
692 *
693 * Used when a booking is marked paid by hand — typically an offline payment
694 * such as a bank transfer or cash, where no gateway callback ever arrives.
695 * Without this the booking claimed the money while the ledger showed
696 * nothing, and the Payments screen stayed empty.
697 *
698 * Written as `completed` because the operator is asserting the funds were
699 * received; `payment_type` reflects whether this settles a balance or is the
700 * only payment on the booking.
701 */
702 private function recordManualPayment(object $booking, int $bookingId, float $amount, float $existingLedger): void
703 {
704 $gateway = (string) ($booking->payment_gateway ?? $booking->payment_method ?? '');
705
706 if (trim($gateway) === '') {
707 // `gateway` is NOT NULL on the payments table.
708 $gateway = 'manual';
709 }
710
711 $this->paymentRepository->create([
712 'booking_id' => $bookingId,
713 'customer_id' => !empty($booking->customer_id) ? (int) $booking->customer_id : null,
714 'gateway' => $gateway,
715 'amount' => $amount,
716 'currency' => (string) ($booking->currency ?? SettingsService::getCurrency()),
717 'status' => 'completed',
718 'payment_type' => $existingLedger > 0 ? 'final' : 'initial',
719 'notes' => __('Recorded manually when the booking was marked as paid.', 'yatra'),
720 'processed_at' => current_time('mysql'),
721 'created_at' => current_time('mysql'),
722 ]);
723
724 do_action('yatra_manual_payment_recorded', $bookingId, $amount, $gateway);
725 }
726
727 /**
728 * React to a manual payment-status change (admin edits, e.g. bank transfer
729 * marked Paid). Sends the customer + admin payment emails when money is
730 * (fully or partially) received, and fires `yatra_payment_status_changed`
731 * so integrations can react. Intentionally separate from
732 * `yatra_payment_completed`, which represents a real gateway capture.
733 */
734 private function handlePaymentStatusChange(int $bookingId, string $oldStatus, string $newStatus): void
735 {
736 $booking = $this->bookingRepository->findWithTrip($bookingId);
737 if (!$booking) {
738 return;
739 }
740
741 do_action('yatra_payment_status_changed', $bookingId, $oldStatus, $newStatus, $booking);
742
743 // Marking a booking paid has to settle its money fields too. An operator
744 // confirming an offline payment (bank transfer, cash) has no payment row
745 // to mark as completed — this status change is the only signal we get.
746 // Without reconciling here the booking read "paid" while amount_paid
747 // stayed 0 and amount_due kept the outstanding figure, so the invoice
748 // still reported "Payment Pending" with nothing paid and the full amount
749 // due.
750 //
751 // Only ever settles UP: a recorded amount_paid at or above the total is
752 // left alone, so this can never erase or reduce a real payment. The other
753 // statuses are deliberately untouched — "partial" carries no amount to
754 // apply, and zeroing on "pending"/"refunded" would destroy payment data.
755 if ($newStatus === 'paid') {
756 $total = (float) ($booking->total_amount ?? 0);
757 $recorded = (float) ($booking->amount_paid ?? 0);
758
759 if ($total > 0) {
760 // The payments ledger is the source of truth: PaymentService
761 // recalculates amount_paid from it whenever a payment is added,
762 // so a booking marked paid without a matching ledger row would
763 // silently revert to "partial" the next time any payment was
764 // recorded. Write the outstanding balance as a real payment so
765 // the two agree and the Payments screen shows what was received.
766 $ledger = (float) $this->paymentRepository->getTotalPaidForBooking($bookingId);
767
768 // Measure the gap against whichever figure is higher so an
769 // existing (pre-ledger) amount_paid is never double-counted.
770 $alreadyCovered = max($ledger, $recorded);
771 $outstanding = round($total - $alreadyCovered, 2);
772
773 if ($outstanding > 0) {
774 $this->recordManualPayment($booking, $bookingId, $outstanding, $ledger);
775 $ledger = (float) $this->paymentRepository->getTotalPaidForBooking($bookingId);
776 }
777
778 // Never reduce a recorded overpayment: settle up, never down.
779 $newAmountPaid = max($ledger, $recorded);
780
781 if ($newAmountPaid > $recorded || $recorded < $total) {
782 // Canonical writer — also derives amount_due and keeps
783 // payment_status consistent with the amounts.
784 $this->bookingRepository->updateAmountPaid($bookingId, $newAmountPaid);
785
786 $booking->amount_paid = $newAmountPaid;
787 $booking->amount_due = max(0.0, $total - $newAmountPaid);
788 }
789 }
790 }
791
792 if (in_array($newStatus, ['paid', 'partial'], true)) {
793 $paidAmount = (float) ($booking->amount_paid ?? 0);
794 if ($paidAmount <= 0) {
795 $paidAmount = (float) ($booking->total_amount ?? 0);
796 }
797 \Yatra\Services\NotificationService::sendPaymentCompletedNotification([
798 'booking_id' => $bookingId,
799 'amount' => $paidAmount,
800 'payment_method' => (string) ($booking->payment_method ?? ''),
801 'transaction_id' => '',
802 ]);
803 }
804 }
805
806 /**
807 * Calculate end date from start date and trip duration
808 *
809 * @param string $startDate Start date (YYYY-MM-DD)
810 * @param int $tripId Trip ID
811 * @return string End date (YYYY-MM-DD)
812 */
813 private function calculateEndDate(string $startDate, int $tripId): string
814 {
815 return $this->bookingRepository->calculateEndDate($startDate, $tripId);
816 }
817
818 /**
819 * Update booking status
820 *
821 * @param int $id Booking ID
822 * @param string $status New status
823 * @return array {success: bool, message: string}
824 */
825 public function updateStatus(int $id, string $status): array
826 {
827 $validStatuses = ['pending', 'confirmed', 'processing', 'completed', 'cancelled', 'refunded', 'failed', 'on_hold'];
828
829 if (!in_array($status, $validStatuses, true)) {
830 return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
831 }
832
833 $booking = $this->bookingRepository->find($id);
834
835 if (!$booking) {
836 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
837 }
838
839 $oldStatus = $booking->status;
840 $updated = $this->bookingRepository->updateStatus($id, $status);
841
842 if (!$updated) {
843 return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
844 }
845
846 if ($oldStatus === 'waitlist' && $status !== 'waitlist') {
847 WaitlistService::releaseWaitlistHolding($booking);
848 }
849
850 // ========================================
851 // HANDLE DEPARTURE BOOKED_COUNT UPDATE
852 // ========================================
853 // If booking is cancelled or refunded, unlink from departure and decrement booked_count
854 // If booking status changes from cancelled/refunded to active, link and increment booked_count
855 try {
856 $departure = $this->departureService->getDepartureForBooking($id);
857 $travelersCount = (int) ($booking->travelers_count ?? 0);
858
859 if ($departure) {
860 // If booking is being cancelled or refunded
861 if (in_array($status, ['cancelled', 'refunded'], true) &&
862 !in_array($oldStatus, ['cancelled', 'refunded'], true)) {
863 // Unlink booking from departure (this will handle cancellation if no bookings remain)
864 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
865 }
866 // If booking status changes from cancelled/refunded back to active
867 elseif (in_array($oldStatus, ['cancelled', 'refunded'], true) &&
868 !in_array($status, ['cancelled', 'refunded'], true)) {
869 // Ensure booking is linked and increment booked count
870 $this->departureService->linkBookingToDeparture($id, $departure->id);
871 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
872 }
873 } elseif (!empty($booking->start_date) || !empty($booking->travel_date)) {
874 // Booking doesn't have a departure yet, but has a date - create and link
875 $startDate = $booking->start_date ?? $booking->travel_date;
876 $endDate = $booking->end_date ?? $this->calculateEndDate($startDate, (int) $booking->trip_id);
877
878 $trip = $this->tripRepository->find((int) $booking->trip_id);
879 // Resolve capacity from the trip's real column (`max_travelers`).
880 // The old `$trip->max_capacity` does not exist on the trips table,
881 // so this always fell back to 9999 — seeding a junk "unlimited"
882 // sentinel that then showed as a huge number on the Departures page
883 // but as 0 on the dashboard. Pass null when unset so
884 // findOrCreateForBooking resolves via Availability, then its own
885 // trip-default fallback, exactly like the primary creation path.
886 $maxCapacity = ($trip && !empty($trip->max_travelers)) ? (int) $trip->max_travelers : null;
887
888 $departure = $this->departureService->findOrCreateForBooking(
889 (int) $booking->trip_id,
890 $startDate,
891 $endDate,
892 $travelersCount,
893 $maxCapacity
894 );
895
896 $this->departureService->linkBookingToDeparture($id, $departure->id);
897 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
898 }
899 } catch (\Exception $e) {
900 // Log error but don't fail the status update
901 }
902
903 // Send status change notification
904 $this->sendStatusChangeNotification($id, $oldStatus, $status);
905
906 /**
907 * Action: Booking status changed
908 * Fires when booking status changes
909 *
910 * @param int $id The booking ID
911 * @param string $oldStatus Previous status
912 * @param string $status New status
913 * @since 3.0.0
914 */
915 do_action('yatra_booking_status_changed', $id, $oldStatus, $status);
916
917 if ($status === 'confirmed' && $oldStatus !== 'confirmed') {
918 \yatra_trigger_booking_confirmed($id, $oldStatus);
919 }
920
921 return [
922 'success' => true,
923 'message' => sprintf(
924 /* translators: %s: new booking status. */
925 __('Booking status updated to %s.', 'yatra'),
926 $status
927 ),
928 ];
929 }
930
931 /**
932 * Delete a booking
933 *
934 * @param int $id Booking ID
935 * @return array {success: bool, message: string}
936 */
937 public function deleteBooking(int $id): array
938 {
939 $booking = $this->bookingRepository->find($id);
940
941 if (!$booking) {
942 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
943 }
944
945 if (($booking->status ?? '') === 'waitlist') {
946 WaitlistService::releaseWaitlistHolding($booking);
947 }
948
949 try {
950 $departure = $this->departureService->getDepartureForBooking($id);
951 if ($departure) {
952 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
953 }
954 } catch (\Throwable $e) {
955 // Continue with delete
956 }
957
958 // Delete related travelers
959 $this->travellerRepository->deleteByBookingId($id);
960
961 // Delete booking
962 $deleted = $this->bookingRepository->delete($id);
963
964 if (!$deleted) {
965 return ['success' => false, 'message' => __('Failed to delete booking.', 'yatra')];
966 }
967
968 if (!is_object($booking)) {
969 $booking = (object) [];
970 }
971
972 do_action('yatra_booking_deleted', (int) $id, $booking);
973
974 return [
975 'success' => true,
976 'message' => __('Booking deleted successfully.', 'yatra'),
977 ];
978 }
979
980 /**
981 * Get booking statistics
982 *
983 * @return array
984 */
985 public function getStats(): array
986 {
987 return $this->bookingRepository->getStats();
988 }
989
990 /**
991 * Get booking payments
992 *
993 * @param int $bookingId Booking ID
994 * @return array
995 */
996 public function getBookingPayments(int $bookingId): array
997 {
998 return $this->paymentRepository->findByBookingId($bookingId);
999 }
1000
1001 /**
1002 * Get booking travelers
1003 *
1004 * @param int $bookingId Booking ID
1005 * @return array
1006 */
1007 public function getBookingTravelers(int $bookingId): array
1008 {
1009 return $this->travellerRepository->getByBookingId($bookingId);
1010 }
1011
1012 /**
1013 * Format booking for API response
1014 *
1015 * @param object $booking Raw booking data
1016 * @return array
1017 */
1018 private function formatBooking(object $booking): array
1019 {
1020 // Build customer name from contact fields
1021 $customerName = trim(
1022 ($booking->customer_first_name ?? $booking->contact_first_name ?? '') . ' ' . ($booking->customer_last_name ?? $booking->contact_last_name ?? '')
1023 ) ?: ($booking->customer_name ?? $booking->customer_email ?? $booking->contact_email ?? null);
1024
1025 $customerEmail = $booking->customer_email ?? $booking->contact_email ?? null;
1026 $customerPhone = $booking->contact_phone ?? null;
1027
1028 // Fallback: fetch customer record if customer_id is set and info missing
1029 if (($booking->customer_id ?? 0) && (empty($customerName) || empty($customerEmail) || empty($customerPhone))) {
1030 $customerRepo = new \Yatra\Repositories\CustomerRepository();
1031 $customerRecord = $customerRepo->find((int)$booking->customer_id);
1032 if ($customerRecord) {
1033 if (empty($customerName)) {
1034 $customerName = trim(($customerRecord->first_name ?? '') . ' ' . ($customerRecord->last_name ?? '')) ?: ($customerRecord->email ?? $customerName);
1035 }
1036 if (empty($customerEmail)) {
1037 $customerEmail = $customerRecord->email ?? $customerEmail;
1038 }
1039 if (empty($customerPhone)) {
1040 $customerPhone = $customerRecord->phone ?? $customerPhone;
1041 }
1042 if (empty($booking->contact_first_name) && !empty($customerRecord->first_name)) {
1043 $booking->contact_first_name = $customerRecord->first_name;
1044 }
1045 if (empty($booking->contact_last_name) && !empty($customerRecord->last_name)) {
1046 $booking->contact_last_name = $customerRecord->last_name;
1047 }
1048 if (empty($booking->contact_country) && !empty($customerRecord->country)) {
1049 $booking->contact_country = $customerRecord->country;
1050 }
1051 }
1052 }
1053
1054 return [
1055 'id' => (int) $booking->id,
1056 'reference' => $booking->reference,
1057 // UI expects booking_number and booking_status fields
1058 'booking_number' => $booking->reference,
1059 'booking_status' => $booking->status,
1060 'trip_id' => (int) $booking->trip_id,
1061 'trip_title' => $booking->trip_title ?? '',
1062 'trip_slug' => $booking->trip_slug ?? '',
1063 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
1064 'user_id' => $booking->user_id ? (int) $booking->user_id : null,
1065 'customer_name' => $customerName,
1066 'customer_email' => $customerEmail,
1067 'customer_phone' => $customerPhone,
1068 'contact' => [
1069 'first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
1070 'last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
1071 'email' => $customerEmail,
1072 'phone' => $customerPhone,
1073 'country' => $booking->contact_country,
1074 ],
1075 'contact_first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
1076 'contact_last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
1077 'contact_email' => $customerEmail,
1078 'contact_phone' => $customerPhone,
1079 'contact_country' => $booking->contact_country ?? null,
1080 'travel_date' => $booking->travel_date,
1081 'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
1082 'end_date' => $booking->end_date ?? null,
1083 // travelers_count stored; also fallback to total_travelers/travelers if present
1084 'travelers_count' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
1085 'travelers' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
1086 'total_amount' => (float) $booking->total_amount,
1087 'amount_paid' => (float) $booking->amount_paid,
1088 'amount_due' => (float) $booking->amount_due,
1089 'discount_amount' => (float) ($booking->discount_amount ?? 0),
1090 'discount_code' => $booking->discount_code ?? null,
1091 'currency' => $booking->currency,
1092 'tax_amount' => (float) ($booking->tax_amount ?? 0),
1093 'tax_rate' => (float) ($booking->tax_rate ?? 0),
1094 'tax_inclusive' => (int) ($booking->tax_inclusive ?? 0),
1095 'tax_details' => $booking->tax_details ?? null,
1096 'tax_breakdown' => $booking->tax_details ? json_decode($booking->tax_details, true) : [],
1097 'subtotal' => (float) ($booking->subtotal ?? $booking->total_amount ?? 0),
1098 'taxable_amount' => (float) (($booking->subtotal ?? $booking->total_amount ?? 0) + ($booking->itinerary_costs_total ?? 0)),
1099 'itinerary_costs' => $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [],
1100 'itinerary_costs_total' => (float) ($booking->itinerary_costs_total ?? 0),
1101 'status' => $booking->status,
1102 'payment_status' => $booking->payment_status,
1103 // Some UIs expect payment_method; map from payment_gateway
1104 'payment_gateway' => $booking->payment_gateway,
1105 'payment_method' => $booking->payment_gateway,
1106 // booking_date is used in admin table; map to created_at
1107 'booking_date' => $booking->created_at,
1108 'created_at' => $booking->created_at,
1109 'updated_at' => $booking->updated_at,
1110 ];
1111 }
1112
1113 /**
1114 * Format booking with all details for single view
1115 *
1116 * @param object $booking Raw booking data
1117 * @return array
1118 */
1119 private function formatBookingWithDetails(object $booking): array
1120 {
1121 $formatted = $this->formatBooking($booking);
1122
1123 // Add customer name for easier access
1124 $formatted['customer_name'] = trim(
1125 ($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? '')
1126 ) ?: null;
1127 $formatted['customer_email'] = $booking->contact_email ?? null;
1128 $formatted['customer_phone'] = $booking->contact_phone ?? null;
1129
1130 // Also add contact fields at root level for backward compatibility
1131 $formatted['contact_first_name'] = $booking->contact_first_name ?? null;
1132 $formatted['contact_last_name'] = $booking->contact_last_name ?? null;
1133 $formatted['contact_email'] = $booking->contact_email ?? null;
1134 $formatted['contact_phone'] = $booking->contact_phone ?? null;
1135 $formatted['contact_country'] = $booking->contact_country ?? null;
1136
1137 // Add full contact data
1138 $formatted['contact_data'] = $booking->contact_data ? json_decode($booking->contact_data, true) : null;
1139
1140 // Add emergency contact: handle JSON, serialized, or array
1141 $emergency = $booking->emergency_contact ?? null;
1142 if (is_string($emergency)) {
1143 $decoded = json_decode($emergency, true);
1144 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
1145 $emergency = $decoded;
1146 } else {
1147 $maybe = maybe_unserialize($emergency);
1148 $emergency = is_array($maybe) ? $maybe : null;
1149 }
1150 } elseif (!is_array($emergency)) {
1151 $emergency = null;
1152 }
1153 $formatted['emergency_contact'] = $emergency;
1154
1155 // Add travelers
1156 $formatted['travelers'] = $this->getBookingTravelers((int) $booking->id);
1157
1158 // Add payments
1159 $formatted['payments'] = $this->getBookingPayments((int) $booking->id);
1160
1161 // Add tax breakdown
1162 $formatted['tax_breakdown'] = BookingTaxService::getBookingTaxBreakdown((array) $formatted);
1163 $formatted['tax_display'] = BookingTaxService::formatBookingTaxDisplay((array) $formatted);
1164
1165 // Add itinerary costs
1166 $formatted['itinerary_costs'] = $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [];
1167 $formatted['itinerary_costs_total'] = (float) ($booking->itinerary_costs_total ?? 0);
1168
1169 // Add additional fields
1170 $formatted['special_requests'] = $booking->special_requests;
1171 $formatted['internal_notes'] = $booking->internal_notes;
1172 $formatted['payment_transaction_id'] = $booking->payment_transaction_id;
1173 $formatted['cancelled_at'] = $booking->cancelled_at;
1174 $formatted['cancellation_reason'] = $booking->cancellation_reason;
1175 $formatted['confirmed_at'] = $booking->confirmed_at;
1176 $formatted['completed_at'] = $booking->completed_at;
1177
1178 /**
1179 * Filter: Add additional services to booking details
1180 * Allows premium modules to include services data in booking response
1181 *
1182 * @param array $services Empty array by default
1183 * @param int $booking_id The booking ID
1184 * @since 3.0.0
1185 */
1186 $formatted['additional_services'] = apply_filters('yatra_booking_get_services', [], (int) $booking->id);
1187
1188 $formatted = apply_filters('yatra_booking_details', $formatted, (int) $booking->id);
1189
1190 return $formatted;
1191 }
1192
1193 /**
1194 * Save travelers for a booking
1195 *
1196 * @param int $bookingId Booking ID
1197 * @param array $travelers Travelers data
1198 */
1199 private function saveTravelers(int $bookingId, array $travelers): void
1200 {
1201 // Re-index defensively so traveller_index / is_lead are positional and
1202 // contiguous regardless of the incoming keys.
1203 $index = 0;
1204 foreach ($travelers as $travelerData) {
1205 if (!is_array($travelerData)) {
1206 continue;
1207 }
1208 $isLead = $index === 0;
1209 // Accept both shapes: a nested { fields: {...} } (repository format)
1210 // or a flat field map (admin BookingForm). Drop non-field meta keys.
1211 $fields = isset($travelerData['fields']) && is_array($travelerData['fields'])
1212 ? $travelerData['fields']
1213 : $travelerData;
1214 unset($fields['is_lead'], $fields['traveller_index'], $fields['id'], $fields['booking_id']);
1215 // create() is the real repository method (createTraveller() never existed);
1216 // it inserts the traveller row and writes every field to the meta table —
1217 // the same method the checkout flow uses.
1218 $this->travellerRepository->create($bookingId, $index, $isLead, $fields);
1219 $index++;
1220 }
1221 }
1222
1223 /**
1224 * Transactional "new booking" email (settings / Pro templates). Used by checkout when the session
1225 * defers email until after payment redirect or sends the rich HTML confirmation at the end.
1226 */
1227 public function sendNewBookingTransactionalConfirmation(int $bookingId): void
1228 {
1229 $this->sendBookingConfirmationEmail($bookingId);
1230 }
1231
1232 /**
1233 * Send booking confirmation email
1234 *
1235 * @param int $bookingId Booking ID
1236 */
1237 private function sendBookingConfirmationEmail(int $bookingId): void
1238 {
1239 $booking = $this->bookingRepository->findWithTrip($bookingId);
1240
1241 if (!$booking || empty($booking->contact_email)) {
1242 return;
1243 }
1244
1245 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1246 $vars['intro_paragraph'] = __('Thank you for your booking! Here are your details:', 'yatra');
1247 $vars['transactional_context'] = 'booking_created';
1248
1249 TransactionalEmailTemplateService::sendIfEnabled(
1250 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1251 $booking->contact_email,
1252 $vars
1253 );
1254 }
1255
1256 /**
1257 * Send status change notification
1258 *
1259 * @param int $bookingId Booking ID
1260 * @param string $oldStatus Previous status
1261 * @param string $newStatus New status
1262 */
1263 private function sendStatusChangeNotification(int $bookingId, string $oldStatus, string $newStatus): void
1264 {
1265 // Only send for certain status changes
1266 $notifyStatuses = ['confirmed', 'cancelled', 'completed'];
1267
1268 if (!in_array($newStatus, $notifyStatuses, true)) {
1269 return;
1270 }
1271
1272 $booking = $this->bookingRepository->findWithTrip($bookingId);
1273
1274 if (!$booking || empty($booking->contact_email)) {
1275 return;
1276 }
1277
1278 if ($newStatus === 'cancelled') {
1279 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1280 $vars['cancellation_reason'] = (string) ($booking->cancellation_reason ?? '');
1281 TransactionalEmailTemplateService::sendIfEnabled(
1282 TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
1283 $booking->contact_email,
1284 $vars
1285 );
1286
1287 return;
1288 }
1289
1290 if ($newStatus === 'confirmed') {
1291 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1292 $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra');
1293 $vars['transactional_context'] = 'status_confirmed';
1294 TransactionalEmailTemplateService::sendIfEnabled(
1295 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1296 $booking->contact_email,
1297 $vars
1298 );
1299
1300 return;
1301 }
1302
1303 if ($newStatus === 'completed') {
1304 $handled = apply_filters('yatra_send_booking_status_email_html', null, $bookingId, $oldStatus, $newStatus, $booking);
1305 if ($handled !== null) {
1306 ReviewReminderService::scheduleReminder($bookingId);
1307
1308 return;
1309 }
1310
1311 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1312 $vars['completion_date'] = date_i18n(get_option('date_format'));
1313 TransactionalEmailTemplateService::sendIfEnabled(
1314 TransactionalEmailTemplateService::TYPE_BOOKING_COMPLETED,
1315 $booking->contact_email,
1316 $vars
1317 );
1318
1319 ReviewReminderService::scheduleReminder($bookingId);
1320
1321 return;
1322 }
1323 }
1324
1325 /**
1326 * Get all travelers with pagination
1327 *
1328 * @param array $filters Filters
1329 * @return array
1330 */
1331 public function getTravelers(array $filters = []): array
1332 {
1333 return $this->travellerRepository->paginate($filters);
1334 }
1335
1336 /**
1337 * Perform bulk actions on travelers
1338 *
1339 * Currently supports only delete.
1340 *
1341 * @param int[] $ids Traveler IDs
1342 * @param string $action Action key (e.g. 'delete')
1343 * @return array {success: bool, message: string}
1344 */
1345 public function bulkTravelers(array $ids, string $action): array
1346 {
1347 $action = trim($action);
1348
1349 if ($action !== 'delete') {
1350 return [
1351 'success' => false,
1352 'message' => __('Invalid traveler bulk action.', 'yatra'),
1353 ];
1354 }
1355
1356 return $this->travellerRepository->bulkDelete($ids);
1357 }
1358
1359 /**
1360 * Send booking email
1361 *
1362 * @param int $bookingId Booking ID
1363 * @param string $emailType Email type (confirmation, reminder, etc.)
1364 * @return array {success: bool, message: string}
1365 */
1366 public function sendEmail(int $bookingId, string $emailType = 'confirmation'): array
1367 {
1368 $booking = $this->bookingRepository->findWithTrip($bookingId);
1369
1370 if (!$booking) {
1371 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
1372 }
1373
1374 // Customer-facing emails need a recipient; admin notifications go to the
1375 // store admin address, so they don't require the booking's contact email.
1376 $customerEmail = (string) ($booking->contact_email ?? '');
1377 $customerTypes = ['confirmation', 'reminder', 'cancellation', 'completed', 'payment_confirmation'];
1378 if (in_array($emailType, $customerTypes, true) && $customerEmail === '') {
1379 return ['success' => false, 'message' => __('No customer email address on this booking.', 'yatra')];
1380 }
1381
1382 switch ($emailType) {
1383 case 'confirmation':
1384 $this->sendBookingConfirmationEmail($bookingId);
1385 break;
1386
1387 case 'reminder':
1388 $this->sendBookingReminderEmail($booking);
1389 break;
1390
1391 case 'cancellation':
1392 // Mirrors sendStatusChangeNotification() so the resent email is
1393 // identical to the automated cancellation email.
1394 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1395 $vars['cancellation_reason'] = (string) ($booking->cancellation_reason ?? '');
1396 TransactionalEmailTemplateService::sendIfEnabled(
1397 TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
1398 $customerEmail,
1399 $vars
1400 );
1401 break;
1402
1403 case 'completed':
1404 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1405 $vars['completion_date'] = date_i18n(get_option('date_format'));
1406 TransactionalEmailTemplateService::sendIfEnabled(
1407 TransactionalEmailTemplateService::TYPE_BOOKING_COMPLETED,
1408 $customerEmail,
1409 $vars
1410 );
1411 break;
1412
1413 case 'payment_confirmation':
1414 $paymentData = $this->buildPaymentDataForResend($booking);
1415 if ($paymentData === null) {
1416 return ['success' => false, 'message' => __('No recorded payment to resend for this booking.', 'yatra')];
1417 }
1418 \Yatra\Services\NotificationService::resendCustomerPaymentEmail($paymentData);
1419 break;
1420
1421 case 'admin_new_booking':
1422 \Yatra\Services\NotificationService::sendBookingCreatedNotification($bookingId, (array) $booking);
1423 break;
1424
1425 case 'admin_payment_received':
1426 $paymentData = $this->buildPaymentDataForResend($booking);
1427 if ($paymentData === null) {
1428 return ['success' => false, 'message' => __('No recorded payment to resend for this booking.', 'yatra')];
1429 }
1430 \Yatra\Services\NotificationService::resendAdminPaymentEmail($paymentData);
1431 break;
1432
1433 default:
1434 return ['success' => false, 'message' => __('Unknown email type.', 'yatra')];
1435 }
1436
1437 return [
1438 'success' => true,
1439 'message' => __('Email sent successfully.', 'yatra'),
1440 ];
1441 }
1442
1443 /**
1444 * Reconstruct the payment-notification payload for a resend from the latest
1445 * payment on the booking (falling back to the booking's own amount_paid /
1446 * gateway when no ledger row exists). Returns null when nothing has been
1447 * paid, so there is no payment to acknowledge.
1448 */
1449 private function buildPaymentDataForResend(object $booking): ?array
1450 {
1451 $bookingId = (int) ($booking->id ?? 0);
1452 $payment = $this->paymentRepository->findLatestByBookingId($bookingId);
1453
1454 $amount = (float) ($payment->amount ?? $booking->amount_paid ?? 0);
1455 if ($amount <= 0) {
1456 return null;
1457 }
1458
1459 return [
1460 'booking_id' => $bookingId,
1461 'amount' => $amount,
1462 'payment_method' => (string) ($payment->gateway ?? $booking->payment_gateway ?? ''),
1463 'transaction_id' => (string) ($payment->transaction_id ?? ''),
1464 ];
1465 }
1466
1467 /**
1468 * Send booking reminder email
1469 *
1470 * @param object $booking Booking data
1471 */
1472 private function sendBookingReminderEmail(object $booking): void
1473 {
1474 $daysUntilTrip = (int) ((strtotime((string) $booking->travel_date) - time()) / 86400);
1475 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1476 $vars['days_until_trip'] = (string) max(0, $daysUntilTrip);
1477 $vars['reminder_days'] = (string) SettingsService::getInt('booking_reminder_days', 3);
1478
1479 $checklist = '<p><strong>' . esc_html__('Preparation checklist', 'yatra') . '</strong></p><ul>'
1480 . '<li>' . esc_html__('Valid government-issued ID', 'yatra') . '</li>'
1481 . '<li>' . esc_html__('Travel insurance', 'yatra') . '</li>'
1482 . '<li>' . esc_html__('Emergency contacts', 'yatra') . '</li>'
1483 . '</ul>';
1484 $vars['reminder_extra_html'] = $checklist;
1485
1486 $sent = TransactionalEmailTemplateService::sendIfEnabled(
1487 TransactionalEmailTemplateService::TYPE_BOOKING_REMINDER,
1488 $booking->contact_email,
1489 $vars
1490 );
1491
1492 if ($sent) {
1493 $this->bookingRepository->update((int) $booking->id, [
1494 'reminder_sent' => 1,
1495 'reminder_sent_at' => current_time('mysql'),
1496 ]);
1497 }
1498 }
1499 }
1500
1501