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 +427 -26 3.0.4 → 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 ];
@@ -363,10 +371,31 @@
363 371
364 372 // Apply tax calculation to booking data
365 373 $data = BookingTaxService::applyTaxToBooking($data);
366 374
367 - // Calculate amounts (recalculated after tax)
368 - $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));
369 398
370 399 // Create booking
371 400 $bookingId = $this->bookingRepository->create($data);
372 401
@@ -447,9 +476,27 @@
447 476 if (!is_object($booking)) {
448 477 $booking = (object) [];
449 478 }
450 479
451 - 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 + }
452 499
453 500 return [
454 501 'success' => true,
455 502 'booking_id' => $bookingId,
@@ -486,8 +533,27 @@
486 533 if (!$booking) {
487 534 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
488 535 }
489 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 +
490 556 // Check if date is being changed
491 557 $oldStartDate = $booking->start_date ?? $booking->travel_date ?? null;
492 558 $newStartDate = $data['start_date'] ?? $data['travel_date'] ?? null;
493 559 $dateChanged = false;
@@ -504,8 +570,10 @@
504 570 $data['end_date'] = $this->calculateEndDate($data['travel_date'], (int) $booking->trip_id);
505 571 }
506 572
507 573 $oldStatus = (string) ($booking->status ?? '');
574 + $oldPaymentStatus = (string) ($booking->payment_status ?? '');
575 + $oldTripId = (int) ($booking->trip_id ?? 0);
508 576
509 577 // Update booking
510 578 $updated = $this->bookingRepository->update($id, $data);
511 579
@@ -517,15 +585,46 @@
517 585 if ($newStatus !== null && $oldStatus === 'waitlist' && $newStatus !== 'waitlist') {
518 586 WaitlistService::releaseWaitlistHolding($booking);
519 587 }
520 588
521 - // Handle departure date change if date was changed
522 - 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)) {
523 621 try {
524 622 $this->departureService->handleBookingDateChange(
525 623 $id,
526 - $data['start_date'],
527 - $data['end_date']
624 + $effectiveStart,
625 + $effectiveEnd,
626 + $departureTimeForUpdate
528 627 );
529 628 } catch (\Exception $e) {
530 629 // Log error but don't fail the update
531 630 }
@@ -538,15 +637,179 @@
538 637 // Save new travelers
539 638 $this->saveTravelers($id, $data['travelers']);
540 639 }
541 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).
542 688 return [
543 689 'success' => true,
544 690 'message' => __('Booking updated successfully.', 'yatra'),
691 + 'data' => $this->bookingRepository->find($id),
545 692 ];
546 693 }
547 694
548 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 + /**
549 812 * Calculate end date from start date and trip duration
550 813 *
551 814 * @param string $startDate Start date (YYYY-MM-DD)
552 815 * @param int $tripId Trip ID
@@ -617,10 +880,17 @@
617 880 $startDate = $booking->start_date ?? $booking->travel_date;
618 881 $endDate = $booking->end_date ?? $this->calculateEndDate($startDate, (int) $booking->trip_id);
619 882
620 883 $trip = $this->tripRepository->find((int) $booking->trip_id);
621 - $maxCapacity = $trip ? ($trip->max_capacity ?? 9999) : 9999;
622 -
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 +
623 893 $departure = $this->departureService->findOrCreateForBooking(
624 894 (int) $booking->trip_id,
625 895 $startDate,
626 896 $endDate,
@@ -652,11 +922,19 @@
652 922 if ($status === 'confirmed' && $oldStatus !== 'confirmed') {
653 923 \yatra_trigger_booking_confirmed($id, $oldStatus);
654 924 }
655 925
926 + if ($status === 'cancelled' && $oldStatus !== 'cancelled') {
927 + \yatra_trigger_booking_cancelled($id, $oldStatus);
928 + }
929 +
656 930 return [
657 931 'success' => true,
658 - '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 + ),
659 937 ];
660 938 }
661 939
662 940 /**
@@ -928,11 +1206,27 @@
928 1206 * @param array $travelers Travelers data
929 1207 */
930 1208 private function saveTravelers(int $bookingId, array $travelers): void
931 1209 {
932 - 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 + }
933 1217 $isLead = $index === 0;
934 - $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++;
935 1229 }
936 1230 }
937 1231
938 1232 /**
@@ -968,10 +1262,39 @@
968 1262 );
969 1263 }
970 1264
971 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 + /**
972 1295 * Send status change notification
973 - *
1296 + *
974 1297 * @param int $bookingId Booking ID
975 1298 * @param string $oldStatus Previous status
976 1299 * @param string $newStatus New status
977 1300 */
@@ -990,12 +1313,14 @@
990 1313 return;
991 1314 }
992 1315
993 1316 if ($newStatus === 'cancelled') {
1317 + $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1318 + $vars['cancellation_reason'] = (string) ($booking->cancellation_reason ?? '');
994 1319 TransactionalEmailTemplateService::sendIfEnabled(
995 1320 TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
996 1321 $booking->contact_email,
997 - TransactionalEmailTemplateService::variablesFromBooking($booking)
1322 + $vars
998 1323 );
999 1324
1000 1325 return;
1001 1326 }
@@ -1000,16 +1325,9 @@
1000 1325 return;
1001 1326 }
1002 1327
1003 1328 if ($newStatus === 'confirmed') {
1004 - $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1005 - $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra');
1006 - $vars['transactional_context'] = 'status_confirmed';
1007 - TransactionalEmailTemplateService::sendIfEnabled(
1008 - TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1009 - $booking->contact_email,
1010 - $vars
1011 - );
1329 + $this->sendBookingConfirmedEmail($bookingId);
1012 1330
1013 1331 return;
1014 1332 }
1015 1333
@@ -1020,12 +1338,14 @@
1020 1338
1021 1339 return;
1022 1340 }
1023 1341
1342 + $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1343 + $vars['completion_date'] = date_i18n(get_option('date_format'));
1024 1344 TransactionalEmailTemplateService::sendIfEnabled(
1025 1345 TransactionalEmailTemplateService::TYPE_BOOKING_COMPLETED,
1026 1346 $booking->contact_email,
1027 - TransactionalEmailTemplateService::variablesFromBooking($booking)
1347 + $vars
1028 1348 );
1029 1349
1030 1350 ReviewReminderService::scheduleReminder($bookingId);
1031 1351
@@ -1081,15 +1401,30 @@
1081 1401 if (!$booking) {
1082 1402 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
1083 1403 }
1084 1404
1085 - if (empty($booking->contact_email)) {
1086 - 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')];
1087 1411 }
1088 1412
1089 1413 switch ($emailType) {
1090 1414 case 'confirmation':
1091 - $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 + }
1092 1427 break;
1093 1428
1094 1429 case 'reminder':
1095 1430 $this->sendBookingReminderEmail($booking);
@@ -1094,8 +1429,50 @@
1094 1429 case 'reminder':
1095 1430 $this->sendBookingReminderEmail($booking);
1096 1431 break;
1097 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 +
1098 1475 default:
1099 1476 return ['success' => false, 'message' => __('Unknown email type.', 'yatra')];
1100 1477 }
1101 1478
@@ -1101,8 +1478,32 @@
1101 1478
1102 1479 return [
1103 1480 'success' => true,
1104 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 ?? ''),
1105 1506 ];
1106 1507 }
1107 1508
1108 1509 /**