PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 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 All 84 releases
← All changes | app/Services/BookingService.php +531 -81 3.0.2.9 → 3.0.16 View file →
@@ -27,8 +27,15 @@
27 27 * @package Yatra\Services
28 28 */
29 29 class BookingService
30 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 +
31 38 private BookingRepository $bookingRepository;
32 39 private PaymentRepository $paymentRepository;
33 40 private TravellerRepository $travellerRepository;
34 41 private CustomerRepository $customerRepository;
@@ -124,8 +131,9 @@
124 131 if ($travelersCount > $maxCapacity) {
125 132 return [
126 133 'success' => false,
127 134 'message' => sprintf(
135 + /* translators: %d: maximum number of travelers allowed. */
128 136 __('Maximum %d travelers allowed for this trip.', 'yatra'),
129 137 $maxCapacity
130 138 )
131 139 ];
@@ -214,8 +222,97 @@
214 222 ];
215 223 }
216 224
217 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 + }
218 315
219 316 Logger::info("After BookingValidator::sanitize", [
220 317 'data_keys' => array_keys($data),
221 318 'has_itinerary_costs' => isset($data['itinerary_costs']),
@@ -274,10 +371,31 @@
274 371
275 372 // Apply tax calculation to booking data
276 373 $data = BookingTaxService::applyTaxToBooking($data);
277 374
278 - // Calculate amounts (recalculated after tax)
279 - $data['amount_due'] = (float) ($data['total_amount'] ?? 0) - (float) ($data['amount_paid'] ?? 0);
375 + // Calculate amount due (recalculated after tax). Honor the selected
376 + // payment method via Pro FlexiblePayments so a deposit/partial
377 + // booking stores the reduced amount due now — not the full total.
378 + // For 'full' (and when Pro is inactive) the filter returns
379 + // total − paid unchanged, so full-payment bookings are unaffected.
380 + // After the deposit is paid, payment completion resets amount_due to
381 + // the remaining balance (total − amount_paid).
382 + $bs_total = (float) ($data['total_amount'] ?? 0);
383 + $bs_paid = (float) ($data['amount_paid'] ?? 0);
384 + $bs_payment_method = strtolower(trim((string) ($data['payment_method'] ?? 'full')));
385 + $bs_due_now = (float) apply_filters(
386 + 'yatra_calculate_amount_due',
387 + $bs_total - $bs_paid,
388 + $bs_total,
389 + $bs_payment_method,
390 + [
391 + 'trip_id' => (int) ($data['trip_id'] ?? 0),
392 + // Tour start lets Pro enforce "pay in full when the tour is
393 + // within the balance-due window" (tour-anchored payments).
394 + 'travel_date' => (string) ($data['travel_date'] ?? ($data['start_date'] ?? '')),
395 + ]
396 + );
397 + $data['amount_due'] = max(0.0, round($bs_due_now, 2));
280 398
281 399 // Create booking
282 400 $bookingId = $this->bookingRepository->create($data);
283 401
@@ -358,9 +476,27 @@
358 476 if (!is_object($booking)) {
359 477 $booking = (object) [];
360 478 }
361 479
362 - do_action(\Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED, (int) $bookingId, $booking);
480 + // Defer the public booking-created action when the row is
481 + // still in `pending_verification`. Sending the booking
482 + // confirmation email and firing analytics integrations
483 + // before the customer has proven the email is theirs would
484 + // (a) leak the booking details to whoever owns that
485 + // address, and (b) inflate conversion metrics with bookings
486 + // that may never be verified. BookingSessionController::
487 + // verify_email() re-fires this action after the status flip
488 + // so every listener (NotificationHooks, EmailAutomation,
489 + // analytics modules) still runs — just *after* verification.
490 + //
491 + // Inventory + cache invalidation aren't routed through this
492 + // action (they're called directly above), so seat-holding
493 + // continues to work while the customer is in the holding
494 + // state.
495 + $bookingStatus = (string) ($data['status'] ?? ($booking->status ?? ''));
496 + if ($bookingStatus !== 'pending_verification') {
497 + do_action(\Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED, (int) $bookingId, $booking);
498 + }
363 499
364 500 return [
365 501 'success' => true,
366 502 'booking_id' => $bookingId,
@@ -397,8 +533,27 @@
397 533 if (!$booking) {
398 534 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
399 535 }
400 536
537 + // Reject an unknown payment status instead of handing it to MySQL. The
538 + // column is an ENUM, so an unrecognised value was silently coerced —
539 + // resetting a fully-paid booking to "pending" while amount_paid kept the
540 + // money that had actually been received, and still returning success.
541 + if (array_key_exists('payment_status', $data)) {
542 + $paymentStatus = (string) $data['payment_status'];
543 +
544 + if (!in_array($paymentStatus, self::PAYMENT_STATUSES, true)) {
545 + return [
546 + 'success' => false,
547 + 'message' => sprintf(
548 + /* translators: %s: the list of accepted payment statuses. */
549 + __('Invalid payment status. Accepted values are: %s.', 'yatra'),
550 + implode(', ', self::PAYMENT_STATUSES)
551 + ),
552 + ];
553 + }
554 + }
555 +
401 556 // Check if date is being changed
402 557 $oldStartDate = $booking->start_date ?? $booking->travel_date ?? null;
403 558 $newStartDate = $data['start_date'] ?? $data['travel_date'] ?? null;
404 559 $dateChanged = false;
@@ -415,8 +570,10 @@
415 570 $data['end_date'] = $this->calculateEndDate($data['travel_date'], (int) $booking->trip_id);
416 571 }
417 572
418 573 $oldStatus = (string) ($booking->status ?? '');
574 + $oldPaymentStatus = (string) ($booking->payment_status ?? '');
575 + $oldTripId = (int) ($booking->trip_id ?? 0);
419 576
420 577 // Update booking
421 578 $updated = $this->bookingRepository->update($id, $data);
422 579
@@ -428,15 +585,46 @@
428 585 if ($newStatus !== null && $oldStatus === 'waitlist' && $newStatus !== 'waitlist') {
429 586 WaitlistService::releaseWaitlistHolding($booking);
430 587 }
431 588
432 - // Handle departure date change if date was changed
433 - if ($dateChanged && !empty($data['start_date']) && !empty($data['end_date'])) {
589 + // Re-link the departure when the date changed OR the operator picked a
590 + // different departure time. A trip running several departures a day needs the
591 + // time as well — moving a booking from the 09:00 to the 14:00 slot is not a
592 + // date change, and without this it silently stayed on the original slot.
593 + $departureTimeForUpdate = null;
594 + if (!empty($data['departure_time']) && is_string($data['departure_time'])) {
595 + $departureTimeForUpdate = trim($data['departure_time']) !== '' ? trim($data['departure_time']) : null;
596 + }
597 +
598 + // Changing the tour (trip_id) also has to move the departure: the booking
599 + // now belongs to a different trip, so its seat has to be released from the
600 + // old trip's departure and taken on the new trip's departure. Previously
601 + // only a date/time change triggered the re-link, so switching Tour A -> Tour B
602 + // left the booking counted against Tour A's departure (and absent from
603 + // Tour B's) — over-selling A and under-selling B.
604 + $tripChanged = isset($data['trip_id'])
605 + && (int) $data['trip_id'] > 0
606 + && (int) $data['trip_id'] !== $oldTripId;
607 +
608 + // handleBookingDateChange() re-reads the booking (already saved with the new
609 + // trip_id above) and needs the effective travel dates. When only the tour
610 + // changed, the date fields aren't in $data, so fall back to the booking's
611 + // current dates rather than skipping the re-link.
612 + $effectiveStart = !empty($data['start_date'])
613 + ? $data['start_date']
614 + : ($booking->start_date ?? $booking->travel_date ?? null);
615 + $effectiveEnd = !empty($data['end_date'])
616 + ? $data['end_date']
617 + : ($booking->end_date ?? $effectiveStart);
618 +
619 + if (($dateChanged || $tripChanged || $departureTimeForUpdate !== null)
620 + && !empty($effectiveStart) && !empty($effectiveEnd)) {
434 621 try {
435 622 $this->departureService->handleBookingDateChange(
436 623 $id,
437 - $data['start_date'],
438 - $data['end_date']
624 + $effectiveStart,
625 + $effectiveEnd,
626 + $departureTimeForUpdate
439 627 );
440 628 } catch (\Exception $e) {
441 629 // Log error but don't fail the update
442 630 }
@@ -449,15 +637,179 @@
449 637 // Save new travelers
450 638 $this->saveTravelers($id, $data['travelers']);
451 639 }
452 640
641 + // A manual payment-status change (e.g. an admin marking an offline
642 + // bank-transfer booking as Paid) fired no notification and no hook before,
643 + // so the customer was never told their payment was received. Detect the
644 + // change and notify — without firing `yatra_payment_completed` (that means
645 + // a real gateway charge and carries capture side effects).
646 + $newPaymentStatus = isset($data['payment_status']) ? (string) $data['payment_status'] : null;
647 + if ($newPaymentStatus !== null && $newPaymentStatus !== $oldPaymentStatus) {
648 + $this->handlePaymentStatusChange($id, $oldPaymentStatus, $newPaymentStatus);
649 + }
650 +
651 + // Changing the status here fired no event at all, so confirming a booking
652 + // from the edit form saved the status and then went silent: no confirmation
653 + // email, no Email Automation sequence (booking.confirmed / .cancelled /
654 + // .completed), no seat release on cancel. Only the status action
655 + // (updateStatus) ever emitted it, which is why the same change appeared to
656 + // work from one screen and not the other.
657 + //
658 + // Fired last, once the travellers and related rows are saved, so listeners
659 + // read the booking's final state — and only on a real transition, so
660 + // re-saving the form without touching the status stays silent.
661 + if ($newStatus !== null && $newStatus !== $oldStatus) {
662 + // Same order as updateStatus(): the notification is sent inline (it is
663 + // not a listener on the action below), then the event fans out.
664 + $this->sendStatusChangeNotification($id, $oldStatus, $newStatus);
665 +
666 + /**
667 + * Fires when a booking's status changes.
668 + *
669 + * @param int $id The booking ID
670 + * @param string $oldStatus Previous status
671 + * @param string $newStatus New status
672 + */
673 + do_action('yatra_booking_status_changed', $id, $oldStatus, $newStatus);
674 +
675 + if ($newStatus === 'confirmed' && $oldStatus !== 'confirmed'
676 + && function_exists('yatra_trigger_booking_confirmed')) {
677 + yatra_trigger_booking_confirmed($id, $oldStatus);
678 + }
679 +
680 + if ($newStatus === 'cancelled' && $oldStatus !== 'cancelled'
681 + && function_exists('yatra_trigger_booking_cancelled')) {
682 + yatra_trigger_booking_cancelled($id, $oldStatus);
683 + }
684 + }
685 +
686 + // Return the fresh booking so the REST controller's `$result['data']`
687 + // is defined (previously absent → "Undefined array key data" warning).
453 688 return [
454 689 'success' => true,
455 690 'message' => __('Booking updated successfully.', 'yatra'),
691 + 'data' => $this->bookingRepository->find($id),
456 692 ];
457 693 }
458 694
459 695 /**
696 + * Record an operator-confirmed payment against a booking.
697 + *
698 + * Used when a booking is marked paid by hand — typically an offline payment
699 + * such as a bank transfer or cash, where no gateway callback ever arrives.
700 + * Without this the booking claimed the money while the ledger showed
701 + * nothing, and the Payments screen stayed empty.
702 + *
703 + * Written as `completed` because the operator is asserting the funds were
704 + * received; `payment_type` reflects whether this settles a balance or is the
705 + * only payment on the booking.
706 + */
707 + private function recordManualPayment(object $booking, int $bookingId, float $amount, float $existingLedger): void
708 + {
709 + $gateway = (string) ($booking->payment_gateway ?? $booking->payment_method ?? '');
710 +
711 + if (trim($gateway) === '') {
712 + // `gateway` is NOT NULL on the payments table.
713 + $gateway = 'manual';
714 + }
715 +
716 + $this->paymentRepository->create([
717 + 'booking_id' => $bookingId,
718 + 'customer_id' => !empty($booking->customer_id) ? (int) $booking->customer_id : null,
719 + 'gateway' => $gateway,
720 + 'amount' => $amount,
721 + 'currency' => (string) ($booking->currency ?? SettingsService::getCurrency()),
722 + 'status' => 'completed',
723 + 'payment_type' => $existingLedger > 0 ? 'final' : 'initial',
724 + 'notes' => __('Recorded manually when the booking was marked as paid.', 'yatra'),
725 + 'processed_at' => current_time('mysql'),
726 + 'created_at' => current_time('mysql'),
727 + ]);
728 +
729 + do_action('yatra_manual_payment_recorded', $bookingId, $amount, $gateway);
730 + }
731 +
732 + /**
733 + * React to a manual payment-status change (admin edits, e.g. bank transfer
734 + * marked Paid). Sends the customer + admin payment emails when money is
735 + * (fully or partially) received, and fires `yatra_payment_status_changed`
736 + * so integrations can react. Intentionally separate from
737 + * `yatra_payment_completed`, which represents a real gateway capture.
738 + */
739 + private function handlePaymentStatusChange(int $bookingId, string $oldStatus, string $newStatus): void
740 + {
741 + $booking = $this->bookingRepository->findWithTrip($bookingId);
742 + if (!$booking) {
743 + return;
744 + }
745 +
746 + do_action('yatra_payment_status_changed', $bookingId, $oldStatus, $newStatus, $booking);
747 +
748 + // Marking a booking paid has to settle its money fields too. An operator
749 + // confirming an offline payment (bank transfer, cash) has no payment row
750 + // to mark as completed — this status change is the only signal we get.
751 + // Without reconciling here the booking read "paid" while amount_paid
752 + // stayed 0 and amount_due kept the outstanding figure, so the invoice
753 + // still reported "Payment Pending" with nothing paid and the full amount
754 + // due.
755 + //
756 + // Only ever settles UP: a recorded amount_paid at or above the total is
757 + // left alone, so this can never erase or reduce a real payment. The other
758 + // statuses are deliberately untouched — "partial" carries no amount to
759 + // apply, and zeroing on "pending"/"refunded" would destroy payment data.
760 + if ($newStatus === 'paid') {
761 + $total = (float) ($booking->total_amount ?? 0);
762 + $recorded = (float) ($booking->amount_paid ?? 0);
763 +
764 + if ($total > 0) {
765 + // The payments ledger is the source of truth: PaymentService
766 + // recalculates amount_paid from it whenever a payment is added,
767 + // so a booking marked paid without a matching ledger row would
768 + // silently revert to "partial" the next time any payment was
769 + // recorded. Write the outstanding balance as a real payment so
770 + // the two agree and the Payments screen shows what was received.
771 + $ledger = (float) $this->paymentRepository->getTotalPaidForBooking($bookingId);
772 +
773 + // Measure the gap against whichever figure is higher so an
774 + // existing (pre-ledger) amount_paid is never double-counted.
775 + $alreadyCovered = max($ledger, $recorded);
776 + $outstanding = round($total - $alreadyCovered, 2);
777 +
778 + if ($outstanding > 0) {
779 + $this->recordManualPayment($booking, $bookingId, $outstanding, $ledger);
780 + $ledger = (float) $this->paymentRepository->getTotalPaidForBooking($bookingId);
781 + }
782 +
783 + // Never reduce a recorded overpayment: settle up, never down.
784 + $newAmountPaid = max($ledger, $recorded);
785 +
786 + if ($newAmountPaid > $recorded || $recorded < $total) {
787 + // Canonical writer — also derives amount_due and keeps
788 + // payment_status consistent with the amounts.
789 + $this->bookingRepository->updateAmountPaid($bookingId, $newAmountPaid);
790 +
791 + $booking->amount_paid = $newAmountPaid;
792 + $booking->amount_due = max(0.0, $total - $newAmountPaid);
793 + }
794 + }
795 + }
796 +
797 + if (in_array($newStatus, ['paid', 'partial'], true)) {
798 + $paidAmount = (float) ($booking->amount_paid ?? 0);
799 + if ($paidAmount <= 0) {
800 + $paidAmount = (float) ($booking->total_amount ?? 0);
801 + }
802 + \Yatra\Services\NotificationService::sendPaymentCompletedNotification([
803 + 'booking_id' => $bookingId,
804 + 'amount' => $paidAmount,
805 + 'payment_method' => (string) ($booking->payment_method ?? ''),
806 + 'transaction_id' => '',
807 + ]);
808 + }
809 + }
810 +
811 + /**
460 812 * Calculate end date from start date and trip duration
461 813 *
462 814 * @param string $startDate Start date (YYYY-MM-DD)
463 815 * @param int $tripId Trip ID
@@ -528,10 +880,17 @@
528 880 $startDate = $booking->start_date ?? $booking->travel_date;
529 881 $endDate = $booking->end_date ?? $this->calculateEndDate($startDate, (int) $booking->trip_id);
530 882
531 883 $trip = $this->tripRepository->find((int) $booking->trip_id);
532 - $maxCapacity = $trip ? ($trip->max_capacity ?? 9999) : 9999;
533 -
884 + // Resolve capacity from the trip's real column (`max_travelers`).
885 + // The old `$trip->max_capacity` does not exist on the trips table,
886 + // so this always fell back to 9999 — seeding a junk "unlimited"
887 + // sentinel that then showed as a huge number on the Departures page
888 + // but as 0 on the dashboard. Pass null when unset so
889 + // findOrCreateForBooking resolves via Availability, then its own
890 + // trip-default fallback, exactly like the primary creation path.
891 + $maxCapacity = ($trip && !empty($trip->max_travelers)) ? (int) $trip->max_travelers : null;
892 +
534 893 $departure = $this->departureService->findOrCreateForBooking(
535 894 (int) $booking->trip_id,
536 895 $startDate,
537 896 $endDate,
@@ -559,11 +918,23 @@
559 918 * @since 3.0.0
560 919 */
561 920 do_action('yatra_booking_status_changed', $id, $oldStatus, $status);
562 921
922 + if ($status === 'confirmed' && $oldStatus !== 'confirmed') {
923 + \yatra_trigger_booking_confirmed($id, $oldStatus);
924 + }
925 +
926 + if ($status === 'cancelled' && $oldStatus !== 'cancelled') {
927 + \yatra_trigger_booking_cancelled($id, $oldStatus);
928 + }
929 +
563 930 return [
564 931 'success' => true,
565 - 'message' => sprintf(__('Booking status updated to %s.', 'yatra'), $status),
932 + 'message' => sprintf(
933 + /* translators: %s: new booking status. */
934 + __('Booking status updated to %s.', 'yatra'),
935 + $status
936 + ),
566 937 ];
567 938 }
568 939
569 940 /**
@@ -715,8 +1086,10 @@
715 1086 'contact_email' => $customerEmail,
716 1087 'contact_phone' => $customerPhone,
717 1088 'contact_country' => $booking->contact_country ?? null,
718 1089 'travel_date' => $booking->travel_date,
1090 + 'start_date' => $booking->start_date ?? $booking->travel_date ?? null,
1091 + 'end_date' => $booking->end_date ?? null,
719 1092 // travelers_count stored; also fallback to total_travelers/travelers if present
720 1093 'travelers_count' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
721 1094 'travelers' => (int) ($booking->travelers_count ?? $booking->total_travelers ?? $booking->travelers ?? 0),
722 1095 'total_amount' => (float) $booking->total_amount,
@@ -833,11 +1206,27 @@
833 1206 * @param array $travelers Travelers data
834 1207 */
835 1208 private function saveTravelers(int $bookingId, array $travelers): void
836 1209 {
837 - foreach ($travelers as $index => $travelerData) {
1210 + // Re-index defensively so traveller_index / is_lead are positional and
1211 + // contiguous regardless of the incoming keys.
1212 + $index = 0;
1213 + foreach ($travelers as $travelerData) {
1214 + if (!is_array($travelerData)) {
1215 + continue;
1216 + }
838 1217 $isLead = $index === 0;
839 - $this->travellerRepository->createTraveller($bookingId, $index, $isLead, $travelerData);
1218 + // Accept both shapes: a nested { fields: {...} } (repository format)
1219 + // or a flat field map (admin BookingForm). Drop non-field meta keys.
1220 + $fields = isset($travelerData['fields']) && is_array($travelerData['fields'])
1221 + ? $travelerData['fields']
1222 + : $travelerData;
1223 + unset($fields['is_lead'], $fields['traveller_index'], $fields['id'], $fields['booking_id']);
1224 + // create() is the real repository method (createTraveller() never existed);
1225 + // it inserts the traveller row and writes every field to the meta table —
1226 + // the same method the checkout flow uses.
1227 + $this->travellerRepository->create($bookingId, $index, $isLead, $fields);
1228 + $index++;
840 1229 }
841 1230 }
842 1231
843 1232 /**
@@ -873,10 +1262,39 @@
873 1262 );
874 1263 }
875 1264
876 1265 /**
1266 + * Send the "your booking has been confirmed" transactional email.
1267 + *
1268 + * Shared by the manual / programmatic status-change path ({@see updateStatus()}
1269 + * → sendStatusChangeNotification()) and the asynchronous payment-completion
1270 + * paths (via {@see yatra_trigger_booking_confirmed()}), which set a booking to
1271 + * `confirmed` with a direct DB write and never pass through updateStatus().
1272 + * Gated by the template's enabled flag (`sendIfEnabled`).
1273 + *
1274 + * @param int $bookingId Booking ID.
1275 + */
1276 + public function sendBookingConfirmedEmail(int $bookingId): void
1277 + {
1278 + $booking = $this->bookingRepository->findWithTrip($bookingId);
1279 +
1280 + if (!$booking || empty($booking->contact_email)) {
1281 + return;
1282 + }
1283 +
1284 + $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1285 + $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra');
1286 + $vars['transactional_context'] = 'status_confirmed';
1287 + TransactionalEmailTemplateService::sendIfEnabled(
1288 + TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1289 + $booking->contact_email,
1290 + $vars
1291 + );
1292 + }
1293 +
1294 + /**
877 1295 * Send status change notification
878 - *
1296 + *
879 1297 * @param int $bookingId Booking ID
880 1298 * @param string $oldStatus Previous status
881 1299 * @param string $newStatus New status
882 1300 */
@@ -895,12 +1313,14 @@
895 1313 return;
896 1314 }
897 1315
898 1316 if ($newStatus === 'cancelled') {
1317 + $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1318 + $vars['cancellation_reason'] = (string) ($booking->cancellation_reason ?? '');
899 1319 TransactionalEmailTemplateService::sendIfEnabled(
900 1320 TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
901 1321 $booking->contact_email,
902 - TransactionalEmailTemplateService::variablesFromBooking($booking)
1322 + $vars
903 1323 );
904 1324
905 1325 return;
906 1326 }
@@ -905,16 +1325,9 @@
905 1325 return;
906 1326 }
907 1327
908 1328 if ($newStatus === 'confirmed') {
909 - $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
910 - $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra');
911 - $vars['transactional_context'] = 'status_confirmed';
912 - TransactionalEmailTemplateService::sendIfEnabled(
913 - TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
914 - $booking->contact_email,
915 - $vars
916 - );
1329 + $this->sendBookingConfirmedEmail($bookingId);
917 1330
918 1331 return;
919 1332 }
920 1333
@@ -920,69 +1333,25 @@
920 1333
921 1334 if ($newStatus === 'completed') {
922 1335 $handled = apply_filters('yatra_send_booking_status_email_html', null, $bookingId, $oldStatus, $newStatus, $booking);
923 1336 if ($handled !== null) {
1337 + ReviewReminderService::scheduleReminder($bookingId);
1338 +
924 1339 return;
925 1340 }
926 - }
927 1341
928 - $subject = sprintf(
929 - __('[%s] Booking Status Update - %s', 'yatra'),
930 - get_bloginfo('name'),
931 - $booking->reference
932 - );
933 - $message = $this->getStatusChangeEmailContent($booking, $newStatus);
1342 + $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1343 + $vars['completion_date'] = date_i18n(get_option('date_format'));
1344 + TransactionalEmailTemplateService::sendIfEnabled(
1345 + TransactionalEmailTemplateService::TYPE_BOOKING_COMPLETED,
1346 + $booking->contact_email,
1347 + $vars
1348 + );
934 1349
935 - EmailService::send(
936 - $booking->contact_email,
937 - $subject,
938 - $message,
939 - ['Content-Type: text/html; charset=UTF-8']
940 - );
941 - }
1350 + ReviewReminderService::scheduleReminder($bookingId);
942 1351
943 - /**
944 - * Get status change email content
945 - *
946 - * @param object $booking Booking data
947 - * @param string $newStatus New status
948 - * @return string HTML email content
949 - */
950 - private function getStatusChangeEmailContent(object $booking, string $newStatus): string
951 - {
952 - $statusMessages = [
953 - 'confirmed' => __('Your booking has been confirmed!', 'yatra'),
954 - 'cancelled' => __('Your booking has been cancelled.', 'yatra'),
955 - 'completed' => __('Your trip has been completed. Thank you for traveling with us!', 'yatra'),
956 - ];
957 -
958 - $message = $statusMessages[$newStatus] ?? sprintf(__('Your booking status has been updated to: %s', 'yatra'), $newStatus);
959 -
960 - ob_start();
961 - ?>
962 - <!DOCTYPE html>
963 - <html>
964 - <head>
965 - <meta charset="UTF-8">
966 - </head>
967 - <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
968 - <h1 style="color: #2563eb;"><?php esc_html_e('Booking Update', 'yatra'); ?></h1>
969 -
970 - <p><?php echo esc_html($message); ?></p>
971 -
972 - <div style="background: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
973 - <p><strong><?php esc_html_e('Reference:', 'yatra'); ?></strong> <?php echo esc_html($booking->reference); ?></p>
974 - <p><strong><?php esc_html_e('Trip:', 'yatra'); ?></strong> <?php echo esc_html($booking->trip_title); ?></p>
975 - <p><strong><?php esc_html_e('Travel Date:', 'yatra'); ?></strong> <?php echo esc_html(date_i18n(get_option('date_format'), strtotime($booking->travel_date))); ?></p>
976 - </div>
977 -
978 - <p style="margin-top: 30px; color: #666; font-size: 14px;">
979 - <?php echo esc_html(get_bloginfo('name')); ?>
980 - </p>
981 - </body>
982 - </html>
983 - <?php
984 - return ob_get_clean();
1352 + return;
1353 + }
985 1354 }
986 1355
987 1356 /**
988 1357 * Get all travelers with pagination
@@ -1032,15 +1401,30 @@
1032 1401 if (!$booking) {
1033 1402 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
1034 1403 }
1035 1404
1036 - if (empty($booking->contact_email)) {
1037 - return ['success' => false, 'message' => __('No email address found.', 'yatra')];
1405 + // Customer-facing emails need a recipient; admin notifications go to the
1406 + // store admin address, so they don't require the booking's contact email.
1407 + $customerEmail = (string) ($booking->contact_email ?? '');
1408 + $customerTypes = ['confirmation', 'reminder', 'cancellation', 'completed', 'payment_confirmation'];
1409 + if (in_array($emailType, $customerTypes, true) && $customerEmail === '') {
1410 + return ['success' => false, 'message' => __('No customer email address on this booking.', 'yatra')];
1038 1411 }
1039 1412
1040 1413 switch ($emailType) {
1041 1414 case 'confirmation':
1042 - $this->sendBookingConfirmationEmail($bookingId);
1415 + // Resend the email that matches the booking's CURRENT state so it
1416 + // is identical to the automated one. A confirmed booking (or a
1417 + // completed one — it was confirmed before it travelled) gets the
1418 + // "booking confirmed" email (status_confirmed context → the
1419 + // `booking_confirmed` / booking.confirmed template); anything
1420 + // still pending gets the initial "booking received" email
1421 + // (booking_created context → the `booking_confirmation` template).
1422 + if (in_array((string) ($booking->status ?? ''), ['confirmed', 'completed'], true)) {
1423 + $this->sendBookingConfirmedEmail($bookingId);
1424 + } else {
1425 + $this->sendBookingConfirmationEmail($bookingId);
1426 + }
1043 1427 break;
1044 1428
1045 1429 case 'reminder':
1046 1430 $this->sendBookingReminderEmail($booking);
@@ -1045,8 +1429,50 @@
1045 1429 case 'reminder':
1046 1430 $this->sendBookingReminderEmail($booking);
1047 1431 break;
1048 1432
1433 + case 'cancellation':
1434 + // Mirrors sendStatusChangeNotification() so the resent email is
1435 + // identical to the automated cancellation email.
1436 + $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1437 + $vars['cancellation_reason'] = (string) ($booking->cancellation_reason ?? '');
1438 + TransactionalEmailTemplateService::sendIfEnabled(
1439 + TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
1440 + $customerEmail,
1441 + $vars
1442 + );
1443 + break;
1444 +
1445 + case 'completed':
1446 + $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1447 + $vars['completion_date'] = date_i18n(get_option('date_format'));
1448 + TransactionalEmailTemplateService::sendIfEnabled(
1449 + TransactionalEmailTemplateService::TYPE_BOOKING_COMPLETED,
1450 + $customerEmail,
1451 + $vars
1452 + );
1453 + break;
1454 +
1455 + case 'payment_confirmation':
1456 + $paymentData = $this->buildPaymentDataForResend($booking);
1457 + if ($paymentData === null) {
1458 + return ['success' => false, 'message' => __('No recorded payment to resend for this booking.', 'yatra')];
1459 + }
1460 + \Yatra\Services\NotificationService::resendCustomerPaymentEmail($paymentData);
1461 + break;
1462 +
1463 + case 'admin_new_booking':
1464 + \Yatra\Services\NotificationService::sendBookingCreatedNotification($bookingId, (array) $booking);
1465 + break;
1466 +
1467 + case 'admin_payment_received':
1468 + $paymentData = $this->buildPaymentDataForResend($booking);
1469 + if ($paymentData === null) {
1470 + return ['success' => false, 'message' => __('No recorded payment to resend for this booking.', 'yatra')];
1471 + }
1472 + \Yatra\Services\NotificationService::resendAdminPaymentEmail($paymentData);
1473 + break;
1474 +
1049 1475 default:
1050 1476 return ['success' => false, 'message' => __('Unknown email type.', 'yatra')];
1051 1477 }
1052 1478
@@ -1052,8 +1478,32 @@
1052 1478
1053 1479 return [
1054 1480 'success' => true,
1055 1481 'message' => __('Email sent successfully.', 'yatra'),
1482 + ];
1483 + }
1484 +
1485 + /**
1486 + * Reconstruct the payment-notification payload for a resend from the latest
1487 + * payment on the booking (falling back to the booking's own amount_paid /
1488 + * gateway when no ledger row exists). Returns null when nothing has been
1489 + * paid, so there is no payment to acknowledge.
1490 + */
1491 + private function buildPaymentDataForResend(object $booking): ?array
1492 + {
1493 + $bookingId = (int) ($booking->id ?? 0);
1494 + $payment = $this->paymentRepository->findLatestByBookingId($bookingId);
1495 +
1496 + $amount = (float) ($payment->amount ?? $booking->amount_paid ?? 0);
1497 + if ($amount <= 0) {
1498 + return null;
1499 + }
1500 +
1501 + return [
1502 + 'booking_id' => $bookingId,
1503 + 'amount' => $amount,
1504 + 'payment_method' => (string) ($payment->gateway ?? $booking->payment_gateway ?? ''),
1505 + 'transaction_id' => (string) ($payment->transaction_id ?? ''),
1056 1506 ];
1057 1507 }
1058 1508
1059 1509 /**