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

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

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