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

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

1,396 lines 58.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 use Yatra\Repositories\BookingRepository;
8 use Yatra\Repositories\PaymentRepository;
9 use Yatra\Repositories\TravellerRepository;
10 use Yatra\Repositories\CustomerRepository;
11 use Yatra\Repositories\TripRepository;
12 use Yatra\Repositories\DepartureRepository;
13 use Yatra\Repositories\BookingDepartureRepository;
14 use Yatra\Validators\BookingValidator;
15 use Yatra\Utils\Logger;
16 use Yatra\Services\BookingTaxService;
17
18 /**
19 * Booking Service
20 *
21 * Contains business logic for bookings.
22 * Uses repositories for data access.
23 *
24 * Note: Does not extend BaseService as it uses multiple repositories
25 * and has specialized booking-related methods.
26 *
27 * @package Yatra\Services
28 */
29 class BookingService
30 {
31 /**
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 ['trip_id' => (int) ($data['trip_id'] ?? 0)]
391 );
392 $data['amount_due'] = max(0.0, round($bs_due_now, 2));
393
394 // Create booking
395 $bookingId = $this->bookingRepository->create($data);
396
397 if (!$bookingId) {
398 Logger::error("Failed to create booking in database", ['data' => $data]);
399 return ['success' => false, 'message' => __('Failed to create booking.', 'yatra')];
400 }
401
402 // Waitlist bookings do not consume departure capacity until promoted.
403 $isWaitlist = isset($data['status']) && $data['status'] === 'waitlist';
404
405 // Link booking to departure if start_date is provided
406 if (!$isWaitlist && !empty($data['start_date']) && !empty($data['end_date'])) {
407 try {
408 $trip = $this->tripRepository->find((int) $data['trip_id']);
409 // Get max capacity from trip's max_travelers, or use default
410 $maxCapacity = null;
411 if ($trip && !empty($trip->max_travelers)) {
412 $maxCapacity = (int) $trip->max_travelers;
413 }
414 $travelersCount = (int) ($data['travelers_count'] ?? 0);
415
416 $departureTime = null;
417 if (!empty($data['departure_time']) && is_string($data['departure_time'])) {
418 $departureTime = trim($data['departure_time']);
419 if ($departureTime === '') {
420 $departureTime = null;
421 }
422 }
423
424 // Find or create departure
425 $departure = $this->departureService->findOrCreateForBooking(
426 (int) $data['trip_id'],
427 $data['start_date'],
428 $data['end_date'],
429 $travelersCount,
430 $maxCapacity,
431 $departureTime
432 );
433
434 // Link booking to departure
435 $this->departureService->linkBookingToDeparture($bookingId, $departure->id);
436
437 // Increment booked count
438 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
439
440 Logger::info("Booking linked to departure", [
441 'booking_id' => $bookingId,
442 'departure_id' => $departure->id
443 ]);
444 } catch (\Exception $e) {
445 // Log error but don't fail the booking
446 Logger::warning("Failed to link booking to departure", [
447 'booking_id' => $bookingId,
448 'error' => $e->getMessage()
449 ]);
450 }
451 }
452
453 // Save travelers
454 if (!empty($data['travelers']) && is_array($data['travelers'])) {
455 $this->saveTravelers($bookingId, $data['travelers']);
456 }
457
458 // Customer confirmation: skip when checkout will send the session email (offline / zero due).
459 if (!$skipInitialCustomerConfirmation) {
460 $this->sendBookingConfirmationEmail($bookingId);
461 }
462
463 $executionTime = microtime(true) - $startTime;
464 Logger::info("Booking created successfully", [
465 'booking_id' => $bookingId,
466 'reference' => $data['reference'],
467 'execution_time' => $executionTime
468 ]);
469
470 $booking = $this->bookingRepository->find((int) $bookingId);
471 if (!is_object($booking)) {
472 $booking = (object) [];
473 }
474
475 // Defer the public booking-created action when the row is
476 // still in `pending_verification`. Sending the booking
477 // confirmation email and firing analytics integrations
478 // before the customer has proven the email is theirs would
479 // (a) leak the booking details to whoever owns that
480 // address, and (b) inflate conversion metrics with bookings
481 // that may never be verified. BookingSessionController::
482 // verify_email() re-fires this action after the status flip
483 // so every listener (NotificationHooks, EmailAutomation,
484 // analytics modules) still runs — just *after* verification.
485 //
486 // Inventory + cache invalidation aren't routed through this
487 // action (they're called directly above), so seat-holding
488 // continues to work while the customer is in the holding
489 // state.
490 $bookingStatus = (string) ($data['status'] ?? ($booking->status ?? ''));
491 if ($bookingStatus !== 'pending_verification') {
492 do_action(\Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED, (int) $bookingId, $booking);
493 }
494
495 return [
496 'success' => true,
497 'booking_id' => $bookingId,
498 'reference' => $data['reference'],
499 'message' => __('Booking created successfully.', 'yatra'),
500 ];
501
502 } catch (\Exception $e) {
503 $executionTime = microtime(true) - $startTime;
504 Logger::error("Booking creation failed", [
505 'trip_id' => $data['trip_id'] ?? null,
506 'execution_time' => $executionTime,
507 'error' => $e->getMessage()
508 ]);
509
510 return [
511 'success' => false,
512 'message' => $e->getMessage()
513 ];
514 }
515 }
516
517 /**
518 * Update a booking
519 *
520 * @param int $id Booking ID
521 * @param array $data Booking data
522 * @return array {success: bool, message: string}
523 */
524 public function updateBooking(int $id, array $data): array
525 {
526 $booking = $this->bookingRepository->find($id);
527
528 if (!$booking) {
529 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
530 }
531
532 // Reject an unknown payment status instead of handing it to MySQL. The
533 // column is an ENUM, so an unrecognised value was silently coerced —
534 // resetting a fully-paid booking to "pending" while amount_paid kept the
535 // money that had actually been received, and still returning success.
536 if (array_key_exists('payment_status', $data)) {
537 $paymentStatus = (string) $data['payment_status'];
538
539 if (!in_array($paymentStatus, self::PAYMENT_STATUSES, true)) {
540 return [
541 'success' => false,
542 'message' => sprintf(
543 /* translators: %s: the list of accepted payment statuses. */
544 __('Invalid payment status. Accepted values are: %s.', 'yatra'),
545 implode(', ', self::PAYMENT_STATUSES)
546 ),
547 ];
548 }
549 }
550
551 // Check if date is being changed
552 $oldStartDate = $booking->start_date ?? $booking->travel_date ?? null;
553 $newStartDate = $data['start_date'] ?? $data['travel_date'] ?? null;
554 $dateChanged = false;
555
556 if ($newStartDate && $oldStartDate && $newStartDate !== $oldStartDate) {
557 $dateChanged = true;
558 }
559
560 // Calculate end_date if start_date is provided
561 if (!empty($data['start_date']) && empty($data['end_date'])) {
562 $data['end_date'] = $this->calculateEndDate($data['start_date'], (int) $booking->trip_id);
563 } elseif (!empty($data['travel_date']) && empty($data['start_date']) && empty($data['end_date'])) {
564 $data['start_date'] = $data['travel_date'];
565 $data['end_date'] = $this->calculateEndDate($data['travel_date'], (int) $booking->trip_id);
566 }
567
568 $oldStatus = (string) ($booking->status ?? '');
569 $oldPaymentStatus = (string) ($booking->payment_status ?? '');
570
571 // Update booking
572 $updated = $this->bookingRepository->update($id, $data);
573
574 if (!$updated) {
575 return ['success' => false, 'message' => __('Failed to update booking.', 'yatra')];
576 }
577
578 $newStatus = isset($data['status']) ? (string) $data['status'] : null;
579 if ($newStatus !== null && $oldStatus === 'waitlist' && $newStatus !== 'waitlist') {
580 WaitlistService::releaseWaitlistHolding($booking);
581 }
582
583 // Re-link the departure when the date changed OR the operator picked a
584 // different departure time. A trip running several departures a day needs the
585 // time as well — moving a booking from the 09:00 to the 14:00 slot is not a
586 // date change, and without this it silently stayed on the original slot.
587 $departureTimeForUpdate = null;
588 if (!empty($data['departure_time']) && is_string($data['departure_time'])) {
589 $departureTimeForUpdate = trim($data['departure_time']) !== '' ? trim($data['departure_time']) : null;
590 }
591
592 if (($dateChanged || $departureTimeForUpdate !== null) && !empty($data['start_date']) && !empty($data['end_date'])) {
593 try {
594 $this->departureService->handleBookingDateChange(
595 $id,
596 $data['start_date'],
597 $data['end_date'],
598 $departureTimeForUpdate
599 );
600 } catch (\Exception $e) {
601 // Log error but don't fail the update
602 }
603 }
604
605 // Update travelers if provided
606 if (isset($data['travelers']) && is_array($data['travelers'])) {
607 // Delete existing travelers
608 $this->travellerRepository->deleteByBookingId($id);
609 // Save new travelers
610 $this->saveTravelers($id, $data['travelers']);
611 }
612
613 // A manual payment-status change (e.g. an admin marking an offline
614 // bank-transfer booking as Paid) fired no notification and no hook before,
615 // so the customer was never told their payment was received. Detect the
616 // change and notify — without firing `yatra_payment_completed` (that means
617 // a real gateway charge and carries capture side effects).
618 $newPaymentStatus = isset($data['payment_status']) ? (string) $data['payment_status'] : null;
619 if ($newPaymentStatus !== null && $newPaymentStatus !== $oldPaymentStatus) {
620 $this->handlePaymentStatusChange($id, $oldPaymentStatus, $newPaymentStatus);
621 }
622
623 // Changing the status here fired no event at all, so confirming a booking
624 // from the edit form saved the status and then went silent: no confirmation
625 // email, no Email Automation sequence (booking.confirmed / .cancelled /
626 // .completed), no seat release on cancel. Only the status action
627 // (updateStatus) ever emitted it, which is why the same change appeared to
628 // work from one screen and not the other.
629 //
630 // Fired last, once the travellers and related rows are saved, so listeners
631 // read the booking's final state — and only on a real transition, so
632 // re-saving the form without touching the status stays silent.
633 if ($newStatus !== null && $newStatus !== $oldStatus) {
634 // Same order as updateStatus(): the notification is sent inline (it is
635 // not a listener on the action below), then the event fans out.
636 $this->sendStatusChangeNotification($id, $oldStatus, $newStatus);
637
638 /**
639 * Fires when a booking's status changes.
640 *
641 * @param int $id The booking ID
642 * @param string $oldStatus Previous status
643 * @param string $newStatus New status
644 */
645 do_action('yatra_booking_status_changed', $id, $oldStatus, $newStatus);
646
647 if ($newStatus === 'confirmed' && $oldStatus !== 'confirmed'
648 && function_exists('yatra_trigger_booking_confirmed')) {
649 yatra_trigger_booking_confirmed($id, $oldStatus);
650 }
651 }
652
653 // Return the fresh booking so the REST controller's `$result['data']`
654 // is defined (previously absent → "Undefined array key data" warning).
655 return [
656 'success' => true,
657 'message' => __('Booking updated successfully.', 'yatra'),
658 'data' => $this->bookingRepository->find($id),
659 ];
660 }
661
662 /**
663 * Record an operator-confirmed payment against a booking.
664 *
665 * Used when a booking is marked paid by hand — typically an offline payment
666 * such as a bank transfer or cash, where no gateway callback ever arrives.
667 * Without this the booking claimed the money while the ledger showed
668 * nothing, and the Payments screen stayed empty.
669 *
670 * Written as `completed` because the operator is asserting the funds were
671 * received; `payment_type` reflects whether this settles a balance or is the
672 * only payment on the booking.
673 */
674 private function recordManualPayment(object $booking, int $bookingId, float $amount, float $existingLedger): void
675 {
676 $gateway = (string) ($booking->payment_gateway ?? $booking->payment_method ?? '');
677
678 if (trim($gateway) === '') {
679 // `gateway` is NOT NULL on the payments table.
680 $gateway = 'manual';
681 }
682
683 $this->paymentRepository->create([
684 'booking_id' => $bookingId,
685 'customer_id' => !empty($booking->customer_id) ? (int) $booking->customer_id : null,
686 'gateway' => $gateway,
687 'amount' => $amount,
688 'currency' => (string) ($booking->currency ?? SettingsService::getCurrency()),
689 'status' => 'completed',
690 'payment_type' => $existingLedger > 0 ? 'final' : 'initial',
691 'notes' => __('Recorded manually when the booking was marked as paid.', 'yatra'),
692 'processed_at' => current_time('mysql'),
693 'created_at' => current_time('mysql'),
694 ]);
695
696 do_action('yatra_manual_payment_recorded', $bookingId, $amount, $gateway);
697 }
698
699 /**
700 * React to a manual payment-status change (admin edits, e.g. bank transfer
701 * marked Paid). Sends the customer + admin payment emails when money is
702 * (fully or partially) received, and fires `yatra_payment_status_changed`
703 * so integrations can react. Intentionally separate from
704 * `yatra_payment_completed`, which represents a real gateway capture.
705 */
706 private function handlePaymentStatusChange(int $bookingId, string $oldStatus, string $newStatus): void
707 {
708 $booking = $this->bookingRepository->findWithTrip($bookingId);
709 if (!$booking) {
710 return;
711 }
712
713 do_action('yatra_payment_status_changed', $bookingId, $oldStatus, $newStatus, $booking);
714
715 // Marking a booking paid has to settle its money fields too. An operator
716 // confirming an offline payment (bank transfer, cash) has no payment row
717 // to mark as completed — this status change is the only signal we get.
718 // Without reconciling here the booking read "paid" while amount_paid
719 // stayed 0 and amount_due kept the outstanding figure, so the invoice
720 // still reported "Payment Pending" with nothing paid and the full amount
721 // due.
722 //
723 // Only ever settles UP: a recorded amount_paid at or above the total is
724 // left alone, so this can never erase or reduce a real payment. The other
725 // statuses are deliberately untouched — "partial" carries no amount to
726 // apply, and zeroing on "pending"/"refunded" would destroy payment data.
727 if ($newStatus === 'paid') {
728 $total = (float) ($booking->total_amount ?? 0);
729 $recorded = (float) ($booking->amount_paid ?? 0);
730
731 if ($total > 0) {
732 // The payments ledger is the source of truth: PaymentService
733 // recalculates amount_paid from it whenever a payment is added,
734 // so a booking marked paid without a matching ledger row would
735 // silently revert to "partial" the next time any payment was
736 // recorded. Write the outstanding balance as a real payment so
737 // the two agree and the Payments screen shows what was received.
738 $ledger = (float) $this->paymentRepository->getTotalPaidForBooking($bookingId);
739
740 // Measure the gap against whichever figure is higher so an
741 // existing (pre-ledger) amount_paid is never double-counted.
742 $alreadyCovered = max($ledger, $recorded);
743 $outstanding = round($total - $alreadyCovered, 2);
744
745 if ($outstanding > 0) {
746 $this->recordManualPayment($booking, $bookingId, $outstanding, $ledger);
747 $ledger = (float) $this->paymentRepository->getTotalPaidForBooking($bookingId);
748 }
749
750 // Never reduce a recorded overpayment: settle up, never down.
751 $newAmountPaid = max($ledger, $recorded);
752
753 if ($newAmountPaid > $recorded || $recorded < $total) {
754 // Canonical writer — also derives amount_due and keeps
755 // payment_status consistent with the amounts.
756 $this->bookingRepository->updateAmountPaid($bookingId, $newAmountPaid);
757
758 $booking->amount_paid = $newAmountPaid;
759 $booking->amount_due = max(0.0, $total - $newAmountPaid);
760 }
761 }
762 }
763
764 if (in_array($newStatus, ['paid', 'partial'], true)) {
765 $paidAmount = (float) ($booking->amount_paid ?? 0);
766 if ($paidAmount <= 0) {
767 $paidAmount = (float) ($booking->total_amount ?? 0);
768 }
769 \Yatra\Services\NotificationService::sendPaymentCompletedNotification([
770 'booking_id' => $bookingId,
771 'amount' => $paidAmount,
772 'payment_method' => (string) ($booking->payment_method ?? ''),
773 'transaction_id' => '',
774 ]);
775 }
776 }
777
778 /**
779 * Calculate end date from start date and trip duration
780 *
781 * @param string $startDate Start date (YYYY-MM-DD)
782 * @param int $tripId Trip ID
783 * @return string End date (YYYY-MM-DD)
784 */
785 private function calculateEndDate(string $startDate, int $tripId): string
786 {
787 return $this->bookingRepository->calculateEndDate($startDate, $tripId);
788 }
789
790 /**
791 * Update booking status
792 *
793 * @param int $id Booking ID
794 * @param string $status New status
795 * @return array {success: bool, message: string}
796 */
797 public function updateStatus(int $id, string $status): array
798 {
799 $validStatuses = ['pending', 'confirmed', 'processing', 'completed', 'cancelled', 'refunded', 'failed', 'on_hold'];
800
801 if (!in_array($status, $validStatuses, true)) {
802 return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
803 }
804
805 $booking = $this->bookingRepository->find($id);
806
807 if (!$booking) {
808 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
809 }
810
811 $oldStatus = $booking->status;
812 $updated = $this->bookingRepository->updateStatus($id, $status);
813
814 if (!$updated) {
815 return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
816 }
817
818 if ($oldStatus === 'waitlist' && $status !== 'waitlist') {
819 WaitlistService::releaseWaitlistHolding($booking);
820 }
821
822 // ========================================
823 // HANDLE DEPARTURE BOOKED_COUNT UPDATE
824 // ========================================
825 // If booking is cancelled or refunded, unlink from departure and decrement booked_count
826 // If booking status changes from cancelled/refunded to active, link and increment booked_count
827 try {
828 $departure = $this->departureService->getDepartureForBooking($id);
829 $travelersCount = (int) ($booking->travelers_count ?? 0);
830
831 if ($departure) {
832 // If booking is being cancelled or refunded
833 if (in_array($status, ['cancelled', 'refunded'], true) &&
834 !in_array($oldStatus, ['cancelled', 'refunded'], true)) {
835 // Unlink booking from departure (this will handle cancellation if no bookings remain)
836 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
837 }
838 // If booking status changes from cancelled/refunded back to active
839 elseif (in_array($oldStatus, ['cancelled', 'refunded'], true) &&
840 !in_array($status, ['cancelled', 'refunded'], true)) {
841 // Ensure booking is linked and increment booked count
842 $this->departureService->linkBookingToDeparture($id, $departure->id);
843 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
844 }
845 } elseif (!empty($booking->start_date) || !empty($booking->travel_date)) {
846 // Booking doesn't have a departure yet, but has a date - create and link
847 $startDate = $booking->start_date ?? $booking->travel_date;
848 $endDate = $booking->end_date ?? $this->calculateEndDate($startDate, (int) $booking->trip_id);
849
850 $trip = $this->tripRepository->find((int) $booking->trip_id);
851 $maxCapacity = $trip ? ($trip->max_capacity ?? 9999) : 9999;
852
853 $departure = $this->departureService->findOrCreateForBooking(
854 (int) $booking->trip_id,
855 $startDate,
856 $endDate,
857 $travelersCount,
858 $maxCapacity
859 );
860
861 $this->departureService->linkBookingToDeparture($id, $departure->id);
862 $this->departureService->incrementBookedCount($departure->id, $travelersCount);
863 }
864 } catch (\Exception $e) {
865 // Log error but don't fail the status update
866 }
867
868 // Send status change notification
869 $this->sendStatusChangeNotification($id, $oldStatus, $status);
870
871 /**
872 * Action: Booking status changed
873 * Fires when booking status changes
874 *
875 * @param int $id The booking ID
876 * @param string $oldStatus Previous status
877 * @param string $status New status
878 * @since 3.0.0
879 */
880 do_action('yatra_booking_status_changed', $id, $oldStatus, $status);
881
882 if ($status === 'confirmed' && $oldStatus !== 'confirmed') {
883 \yatra_trigger_booking_confirmed($id, $oldStatus);
884 }
885
886 return [
887 'success' => true,
888 'message' => sprintf(
889 /* translators: %s: new booking status. */
890 __('Booking status updated to %s.', 'yatra'),
891 $status
892 ),
893 ];
894 }
895
896 /**
897 * Delete a booking
898 *
899 * @param int $id Booking ID
900 * @return array {success: bool, message: string}
901 */
902 public function deleteBooking(int $id): array
903 {
904 $booking = $this->bookingRepository->find($id);
905
906 if (!$booking) {
907 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
908 }
909
910 if (($booking->status ?? '') === 'waitlist') {
911 WaitlistService::releaseWaitlistHolding($booking);
912 }
913
914 try {
915 $departure = $this->departureService->getDepartureForBooking($id);
916 if ($departure) {
917 $this->departureService->unlinkBookingFromDeparture($id, $departure->id);
918 }
919 } catch (\Throwable $e) {
920 // Continue with delete
921 }
922
923 // Delete related travelers
924 $this->travellerRepository->deleteByBookingId($id);
925
926 // Delete booking
927 $deleted = $this->bookingRepository->delete($id);
928
929 if (!$deleted) {
930 return ['success' => false, 'message' => __('Failed to delete booking.', 'yatra')];
931 }
932
933 if (!is_object($booking)) {
934 $booking = (object) [];
935 }
936
937 do_action('yatra_booking_deleted', (int) $id, $booking);
938
939 return [
940 'success' => true,
941 'message' => __('Booking deleted successfully.', 'yatra'),
942 ];
943 }
944
945 /**
946 * Get booking statistics
947 *
948 * @return array
949 */
950 public function getStats(): array
951 {
952 return $this->bookingRepository->getStats();
953 }
954
955 /**
956 * Get booking payments
957 *
958 * @param int $bookingId Booking ID
959 * @return array
960 */
961 public function getBookingPayments(int $bookingId): array
962 {
963 return $this->paymentRepository->findByBookingId($bookingId);
964 }
965
966 /**
967 * Get booking travelers
968 *
969 * @param int $bookingId Booking ID
970 * @return array
971 */
972 public function getBookingTravelers(int $bookingId): array
973 {
974 return $this->travellerRepository->getByBookingId($bookingId);
975 }
976
977 /**
978 * Format booking for API response
979 *
980 * @param object $booking Raw booking data
981 * @return array
982 */
983 private function formatBooking(object $booking): array
984 {
985 // Build customer name from contact fields
986 $customerName = trim(
987 ($booking->customer_first_name ?? $booking->contact_first_name ?? '') . ' ' . ($booking->customer_last_name ?? $booking->contact_last_name ?? '')
988 ) ?: ($booking->customer_name ?? $booking->customer_email ?? $booking->contact_email ?? null);
989
990 $customerEmail = $booking->customer_email ?? $booking->contact_email ?? null;
991 $customerPhone = $booking->contact_phone ?? null;
992
993 // Fallback: fetch customer record if customer_id is set and info missing
994 if (($booking->customer_id ?? 0) && (empty($customerName) || empty($customerEmail) || empty($customerPhone))) {
995 $customerRepo = new \Yatra\Repositories\CustomerRepository();
996 $customerRecord = $customerRepo->find((int)$booking->customer_id);
997 if ($customerRecord) {
998 if (empty($customerName)) {
999 $customerName = trim(($customerRecord->first_name ?? '') . ' ' . ($customerRecord->last_name ?? '')) ?: ($customerRecord->email ?? $customerName);
1000 }
1001 if (empty($customerEmail)) {
1002 $customerEmail = $customerRecord->email ?? $customerEmail;
1003 }
1004 if (empty($customerPhone)) {
1005 $customerPhone = $customerRecord->phone ?? $customerPhone;
1006 }
1007 if (empty($booking->contact_first_name) && !empty($customerRecord->first_name)) {
1008 $booking->contact_first_name = $customerRecord->first_name;
1009 }
1010 if (empty($booking->contact_last_name) && !empty($customerRecord->last_name)) {
1011 $booking->contact_last_name = $customerRecord->last_name;
1012 }
1013 if (empty($booking->contact_country) && !empty($customerRecord->country)) {
1014 $booking->contact_country = $customerRecord->country;
1015 }
1016 }
1017 }
1018
1019 return [
1020 'id' => (int) $booking->id,
1021 'reference' => $booking->reference,
1022 // UI expects booking_number and booking_status fields
1023 'booking_number' => $booking->reference,
1024 'booking_status' => $booking->status,
1025 'trip_id' => (int) $booking->trip_id,
1026 'trip_title' => $booking->trip_title ?? '',
1027 'trip_slug' => $booking->trip_slug ?? '',
1028 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
1029 'user_id' => $booking->user_id ? (int) $booking->user_id : null,
1030 'customer_name' => $customerName,
1031 'customer_email' => $customerEmail,
1032 'customer_phone' => $customerPhone,
1033 'contact' => [
1034 'first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
1035 'last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
1036 'email' => $customerEmail,
1037 'phone' => $customerPhone,
1038 'country' => $booking->contact_country,
1039 ],
1040 'contact_first_name' => $booking->contact_first_name ?? $booking->customer_first_name ?? null,
1041 'contact_last_name' => $booking->contact_last_name ?? $booking->customer_last_name ?? null,
1042 'contact_email' => $customerEmail,
1043 'contact_phone' => $customerPhone,
1044 'contact_country' => $booking->contact_country ?? null,
1045 'travel_date' => $booking->travel_date,
1046 'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
1047 'end_date' => $booking->end_date ?? null,
1048 // travelers_count stored; also fallback to total_travelers/travelers if present
1049 'travelers_count' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
1050 'travelers' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
1051 'total_amount' => (float) $booking->total_amount,
1052 'amount_paid' => (float) $booking->amount_paid,
1053 'amount_due' => (float) $booking->amount_due,
1054 'discount_amount' => (float) ($booking->discount_amount ?? 0),
1055 'discount_code' => $booking->discount_code ?? null,
1056 'currency' => $booking->currency,
1057 'tax_amount' => (float) ($booking->tax_amount ?? 0),
1058 'tax_rate' => (float) ($booking->tax_rate ?? 0),
1059 'tax_inclusive' => (int) ($booking->tax_inclusive ?? 0),
1060 'tax_details' => $booking->tax_details ?? null,
1061 'tax_breakdown' => $booking->tax_details ? json_decode($booking->tax_details, true) : [],
1062 'subtotal' => (float) ($booking->subtotal ?? $booking->total_amount ?? 0),
1063 'taxable_amount' => (float) (($booking->subtotal ?? $booking->total_amount ?? 0) + ($booking->itinerary_costs_total ?? 0)),
1064 'itinerary_costs' => $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [],
1065 'itinerary_costs_total' => (float) ($booking->itinerary_costs_total ?? 0),
1066 'status' => $booking->status,
1067 'payment_status' => $booking->payment_status,
1068 // Some UIs expect payment_method; map from payment_gateway
1069 'payment_gateway' => $booking->payment_gateway,
1070 'payment_method' => $booking->payment_gateway,
1071 // booking_date is used in admin table; map to created_at
1072 'booking_date' => $booking->created_at,
1073 'created_at' => $booking->created_at,
1074 'updated_at' => $booking->updated_at,
1075 ];
1076 }
1077
1078 /**
1079 * Format booking with all details for single view
1080 *
1081 * @param object $booking Raw booking data
1082 * @return array
1083 */
1084 private function formatBookingWithDetails(object $booking): array
1085 {
1086 $formatted = $this->formatBooking($booking);
1087
1088 // Add customer name for easier access
1089 $formatted['customer_name'] = trim(
1090 ($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? '')
1091 ) ?: null;
1092 $formatted['customer_email'] = $booking->contact_email ?? null;
1093 $formatted['customer_phone'] = $booking->contact_phone ?? null;
1094
1095 // Also add contact fields at root level for backward compatibility
1096 $formatted['contact_first_name'] = $booking->contact_first_name ?? null;
1097 $formatted['contact_last_name'] = $booking->contact_last_name ?? null;
1098 $formatted['contact_email'] = $booking->contact_email ?? null;
1099 $formatted['contact_phone'] = $booking->contact_phone ?? null;
1100 $formatted['contact_country'] = $booking->contact_country ?? null;
1101
1102 // Add full contact data
1103 $formatted['contact_data'] = $booking->contact_data ? json_decode($booking->contact_data, true) : null;
1104
1105 // Add emergency contact: handle JSON, serialized, or array
1106 $emergency = $booking->emergency_contact ?? null;
1107 if (is_string($emergency)) {
1108 $decoded = json_decode($emergency, true);
1109 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
1110 $emergency = $decoded;
1111 } else {
1112 $maybe = maybe_unserialize($emergency);
1113 $emergency = is_array($maybe) ? $maybe : null;
1114 }
1115 } elseif (!is_array($emergency)) {
1116 $emergency = null;
1117 }
1118 $formatted['emergency_contact'] = $emergency;
1119
1120 // Add travelers
1121 $formatted['travelers'] = $this->getBookingTravelers((int) $booking->id);
1122
1123 // Add payments
1124 $formatted['payments'] = $this->getBookingPayments((int) $booking->id);
1125
1126 // Add tax breakdown
1127 $formatted['tax_breakdown'] = BookingTaxService::getBookingTaxBreakdown((array) $formatted);
1128 $formatted['tax_display'] = BookingTaxService::formatBookingTaxDisplay((array) $formatted);
1129
1130 // Add itinerary costs
1131 $formatted['itinerary_costs'] = $booking->itinerary_costs ? json_decode($booking->itinerary_costs, true) : [];
1132 $formatted['itinerary_costs_total'] = (float) ($booking->itinerary_costs_total ?? 0);
1133
1134 // Add additional fields
1135 $formatted['special_requests'] = $booking->special_requests;
1136 $formatted['internal_notes'] = $booking->internal_notes;
1137 $formatted['payment_transaction_id'] = $booking->payment_transaction_id;
1138 $formatted['cancelled_at'] = $booking->cancelled_at;
1139 $formatted['cancellation_reason'] = $booking->cancellation_reason;
1140 $formatted['confirmed_at'] = $booking->confirmed_at;
1141 $formatted['completed_at'] = $booking->completed_at;
1142
1143 /**
1144 * Filter: Add additional services to booking details
1145 * Allows premium modules to include services data in booking response
1146 *
1147 * @param array $services Empty array by default
1148 * @param int $booking_id The booking ID
1149 * @since 3.0.0
1150 */
1151 $formatted['additional_services'] = apply_filters('yatra_booking_get_services', [], (int) $booking->id);
1152
1153 $formatted = apply_filters('yatra_booking_details', $formatted, (int) $booking->id);
1154
1155 return $formatted;
1156 }
1157
1158 /**
1159 * Save travelers for a booking
1160 *
1161 * @param int $bookingId Booking ID
1162 * @param array $travelers Travelers data
1163 */
1164 private function saveTravelers(int $bookingId, array $travelers): void
1165 {
1166 // Re-index defensively so traveller_index / is_lead are positional and
1167 // contiguous regardless of the incoming keys.
1168 $index = 0;
1169 foreach ($travelers as $travelerData) {
1170 if (!is_array($travelerData)) {
1171 continue;
1172 }
1173 $isLead = $index === 0;
1174 // Accept both shapes: a nested { fields: {...} } (repository format)
1175 // or a flat field map (admin BookingForm). Drop non-field meta keys.
1176 $fields = isset($travelerData['fields']) && is_array($travelerData['fields'])
1177 ? $travelerData['fields']
1178 : $travelerData;
1179 unset($fields['is_lead'], $fields['traveller_index'], $fields['id'], $fields['booking_id']);
1180 // create() is the real repository method (createTraveller() never existed);
1181 // it inserts the traveller row and writes every field to the meta table —
1182 // the same method the checkout flow uses.
1183 $this->travellerRepository->create($bookingId, $index, $isLead, $fields);
1184 $index++;
1185 }
1186 }
1187
1188 /**
1189 * Transactional "new booking" email (settings / Pro templates). Used by checkout when the session
1190 * defers email until after payment redirect or sends the rich HTML confirmation at the end.
1191 */
1192 public function sendNewBookingTransactionalConfirmation(int $bookingId): void
1193 {
1194 $this->sendBookingConfirmationEmail($bookingId);
1195 }
1196
1197 /**
1198 * Send booking confirmation email
1199 *
1200 * @param int $bookingId Booking ID
1201 */
1202 private function sendBookingConfirmationEmail(int $bookingId): void
1203 {
1204 $booking = $this->bookingRepository->findWithTrip($bookingId);
1205
1206 if (!$booking || empty($booking->contact_email)) {
1207 return;
1208 }
1209
1210 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1211 $vars['intro_paragraph'] = __('Thank you for your booking! Here are your details:', 'yatra');
1212 $vars['transactional_context'] = 'booking_created';
1213
1214 TransactionalEmailTemplateService::sendIfEnabled(
1215 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1216 $booking->contact_email,
1217 $vars
1218 );
1219 }
1220
1221 /**
1222 * Send status change notification
1223 *
1224 * @param int $bookingId Booking ID
1225 * @param string $oldStatus Previous status
1226 * @param string $newStatus New status
1227 */
1228 private function sendStatusChangeNotification(int $bookingId, string $oldStatus, string $newStatus): void
1229 {
1230 // Only send for certain status changes
1231 $notifyStatuses = ['confirmed', 'cancelled', 'completed'];
1232
1233 if (!in_array($newStatus, $notifyStatuses, true)) {
1234 return;
1235 }
1236
1237 $booking = $this->bookingRepository->findWithTrip($bookingId);
1238
1239 if (!$booking || empty($booking->contact_email)) {
1240 return;
1241 }
1242
1243 if ($newStatus === 'cancelled') {
1244 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1245 $vars['cancellation_reason'] = (string) ($booking->cancellation_reason ?? '');
1246 TransactionalEmailTemplateService::sendIfEnabled(
1247 TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
1248 $booking->contact_email,
1249 $vars
1250 );
1251
1252 return;
1253 }
1254
1255 if ($newStatus === 'confirmed') {
1256 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1257 $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra');
1258 $vars['transactional_context'] = 'status_confirmed';
1259 TransactionalEmailTemplateService::sendIfEnabled(
1260 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1261 $booking->contact_email,
1262 $vars
1263 );
1264
1265 return;
1266 }
1267
1268 if ($newStatus === 'completed') {
1269 $handled = apply_filters('yatra_send_booking_status_email_html', null, $bookingId, $oldStatus, $newStatus, $booking);
1270 if ($handled !== null) {
1271 ReviewReminderService::scheduleReminder($bookingId);
1272
1273 return;
1274 }
1275
1276 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1277 $vars['completion_date'] = date_i18n(get_option('date_format'));
1278 TransactionalEmailTemplateService::sendIfEnabled(
1279 TransactionalEmailTemplateService::TYPE_BOOKING_COMPLETED,
1280 $booking->contact_email,
1281 $vars
1282 );
1283
1284 ReviewReminderService::scheduleReminder($bookingId);
1285
1286 return;
1287 }
1288 }
1289
1290 /**
1291 * Get all travelers with pagination
1292 *
1293 * @param array $filters Filters
1294 * @return array
1295 */
1296 public function getTravelers(array $filters = []): array
1297 {
1298 return $this->travellerRepository->paginate($filters);
1299 }
1300
1301 /**
1302 * Perform bulk actions on travelers
1303 *
1304 * Currently supports only delete.
1305 *
1306 * @param int[] $ids Traveler IDs
1307 * @param string $action Action key (e.g. 'delete')
1308 * @return array {success: bool, message: string}
1309 */
1310 public function bulkTravelers(array $ids, string $action): array
1311 {
1312 $action = trim($action);
1313
1314 if ($action !== 'delete') {
1315 return [
1316 'success' => false,
1317 'message' => __('Invalid traveler bulk action.', 'yatra'),
1318 ];
1319 }
1320
1321 return $this->travellerRepository->bulkDelete($ids);
1322 }
1323
1324 /**
1325 * Send booking email
1326 *
1327 * @param int $bookingId Booking ID
1328 * @param string $emailType Email type (confirmation, reminder, etc.)
1329 * @return array {success: bool, message: string}
1330 */
1331 public function sendEmail(int $bookingId, string $emailType = 'confirmation'): array
1332 {
1333 $booking = $this->bookingRepository->findWithTrip($bookingId);
1334
1335 if (!$booking) {
1336 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
1337 }
1338
1339 if (empty($booking->contact_email)) {
1340 return ['success' => false, 'message' => __('No email address found.', 'yatra')];
1341 }
1342
1343 switch ($emailType) {
1344 case 'confirmation':
1345 $this->sendBookingConfirmationEmail($bookingId);
1346 break;
1347
1348 case 'reminder':
1349 $this->sendBookingReminderEmail($booking);
1350 break;
1351
1352 default:
1353 return ['success' => false, 'message' => __('Unknown email type.', 'yatra')];
1354 }
1355
1356 return [
1357 'success' => true,
1358 'message' => __('Email sent successfully.', 'yatra'),
1359 ];
1360 }
1361
1362 /**
1363 * Send booking reminder email
1364 *
1365 * @param object $booking Booking data
1366 */
1367 private function sendBookingReminderEmail(object $booking): void
1368 {
1369 $daysUntilTrip = (int) ((strtotime((string) $booking->travel_date) - time()) / 86400);
1370 $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1371 $vars['days_until_trip'] = (string) max(0, $daysUntilTrip);
1372 $vars['reminder_days'] = (string) SettingsService::getInt('booking_reminder_days', 3);
1373
1374 $checklist = '<p><strong>' . esc_html__('Preparation checklist', 'yatra') . '</strong></p><ul>'
1375 . '<li>' . esc_html__('Valid government-issued ID', 'yatra') . '</li>'
1376 . '<li>' . esc_html__('Travel insurance', 'yatra') . '</li>'
1377 . '<li>' . esc_html__('Emergency contacts', 'yatra') . '</li>'
1378 . '</ul>';
1379 $vars['reminder_extra_html'] = $checklist;
1380
1381 $sent = TransactionalEmailTemplateService::sendIfEnabled(
1382 TransactionalEmailTemplateService::TYPE_BOOKING_REMINDER,
1383 $booking->contact_email,
1384 $vars
1385 );
1386
1387 if ($sent) {
1388 $this->bookingRepository->update((int) $booking->id, [
1389 'reminder_sent' => 1,
1390 'reminder_sent_at' => current_time('mysql'),
1391 ]);
1392 }
1393 }
1394 }
1395
1396