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 +516 -26 3.0.3 → 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,
@@ -563,11 +922,19 @@
563 922 if ($status === 'confirmed' && $oldStatus !== 'confirmed') {
564 923 \yatra_trigger_booking_confirmed($id, $oldStatus);
565 924 }
566 925
926 + if ($status === 'cancelled' && $oldStatus !== 'cancelled') {
927 + \yatra_trigger_booking_cancelled($id, $oldStatus);
928 + }
929 +
567 930 return [
568 931 'success' => true,
569 - '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 + ),
570 937 ];
571 938 }
572 939
573 940 /**
@@ -839,11 +1206,27 @@
839 1206 * @param array $travelers Travelers data
840 1207 */
841 1208 private function saveTravelers(int $bookingId, array $travelers): void
842 1209 {
843 - 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 + }
844 1217 $isLead = $index === 0;
845 - $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++;
846 1229 }
847 1230 }
848 1231
849 1232 /**
@@ -879,10 +1262,39 @@
879 1262 );
880 1263 }
881 1264
882 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 + /**
883 1295 * Send status change notification
884 - *
1296 + *
885 1297 * @param int $bookingId Booking ID
886 1298 * @param string $oldStatus Previous status
887 1299 * @param string $newStatus New status
888 1300 */
@@ -901,12 +1313,14 @@
901 1313 return;
902 1314 }
903 1315
904 1316 if ($newStatus === 'cancelled') {
1317 + $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1318 + $vars['cancellation_reason'] = (string) ($booking->cancellation_reason ?? '');
905 1319 TransactionalEmailTemplateService::sendIfEnabled(
906 1320 TransactionalEmailTemplateService::TYPE_BOOKING_CANCELLATION,
907 1321 $booking->contact_email,
908 - TransactionalEmailTemplateService::variablesFromBooking($booking)
1322 + $vars
909 1323 );
910 1324
911 1325 return;
912 1326 }
@@ -911,16 +1325,9 @@
911 1325 return;
912 1326 }
913 1327
914 1328 if ($newStatus === 'confirmed') {
915 - $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
916 - $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra');
917 - $vars['transactional_context'] = 'status_confirmed';
918 - TransactionalEmailTemplateService::sendIfEnabled(
919 - TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
920 - $booking->contact_email,
921 - $vars
922 - );
1329 + $this->sendBookingConfirmedEmail($bookingId);
923 1330
924 1331 return;
925 1332 }
926 1333
@@ -931,12 +1338,14 @@
931 1338
932 1339 return;
933 1340 }
934 1341
1342 + $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1343 + $vars['completion_date'] = date_i18n(get_option('date_format'));
935 1344 TransactionalEmailTemplateService::sendIfEnabled(
936 1345 TransactionalEmailTemplateService::TYPE_BOOKING_COMPLETED,
937 1346 $booking->contact_email,
938 - TransactionalEmailTemplateService::variablesFromBooking($booking)
1347 + $vars
939 1348 );
940 1349
941 1350 ReviewReminderService::scheduleReminder($bookingId);
942 1351
@@ -992,15 +1401,30 @@
992 1401 if (!$booking) {
993 1402 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
994 1403 }
995 1404
996 - if (empty($booking->contact_email)) {
997 - 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')];
998 1411 }
999 1412
1000 1413 switch ($emailType) {
1001 1414 case 'confirmation':
1002 - $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 + }
1003 1427 break;
1004 1428
1005 1429 case 'reminder':
1006 1430 $this->sendBookingReminderEmail($booking);
@@ -1005,8 +1429,50 @@
1005 1429 case 'reminder':
1006 1430 $this->sendBookingReminderEmail($booking);
1007 1431 break;
1008 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 +
1009 1475 default:
1010 1476 return ['success' => false, 'message' => __('Unknown email type.', 'yatra')];
1011 1477 }
1012 1478
@@ -1012,8 +1478,32 @@
1012 1478
1013 1479 return [
1014 1480 'success' => true,
1015 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 ?? ''),
1016 1506 ];
1017 1507 }
1018 1508
1019 1509 /**