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 +205 -19 3.0.11 → 3.0.16 View file →
@@ -386,9 +386,14 @@
386 386 'yatra_calculate_amount_due',
387 387 $bs_total - $bs_paid,
388 388 $bs_total,
389 389 $bs_payment_method,
390 - ['trip_id' => (int) ($data['trip_id'] ?? 0)]
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 + ]
391 396 );
392 397 $data['amount_due'] = max(0.0, round($bs_due_now, 2));
393 398
394 399 // Create booking
@@ -566,8 +571,9 @@
566 571 }
567 572
568 573 $oldStatus = (string) ($booking->status ?? '');
569 574 $oldPaymentStatus = (string) ($booking->payment_status ?? '');
575 + $oldTripId = (int) ($booking->trip_id ?? 0);
570 576
571 577 // Update booking
572 578 $updated = $this->bookingRepository->update($id, $data);
573 579
@@ -579,15 +585,46 @@
579 585 if ($newStatus !== null && $oldStatus === 'waitlist' && $newStatus !== 'waitlist') {
580 586 WaitlistService::releaseWaitlistHolding($booking);
581 587 }
582 588
583 - // Handle departure date change if date was changed
584 - 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)) {
585 621 try {
586 622 $this->departureService->handleBookingDateChange(
587 623 $id,
588 - $data['start_date'],
589 - $data['end_date']
624 + $effectiveStart,
625 + $effectiveEnd,
626 + $departureTimeForUpdate
590 627 );
591 628 } catch (\Exception $e) {
592 629 // Log error but don't fail the update
593 630 }
@@ -610,8 +647,43 @@
610 647 if ($newPaymentStatus !== null && $newPaymentStatus !== $oldPaymentStatus) {
611 648 $this->handlePaymentStatusChange($id, $oldPaymentStatus, $newPaymentStatus);
612 649 }
613 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 +
614 686 // Return the fresh booking so the REST controller's `$result['data']`
615 687 // is defined (previously absent → "Undefined array key data" warning).
616 688 return [
617 689 'success' => true,
@@ -808,10 +880,17 @@
808 880 $startDate = $booking->start_date ?? $booking->travel_date;
809 881 $endDate = $booking->end_date ?? $this->calculateEndDate($startDate, (int) $booking->trip_id);
810 882
811 883 $trip = $this->tripRepository->find((int) $booking->trip_id);
812 - $maxCapacity = $trip ? ($trip->max_capacity ?? 9999) : 9999;
813 -
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 +
814 893 $departure = $this->departureService->findOrCreateForBooking(
815 894 (int) $booking->trip_id,
816 895 $startDate,
817 896 $endDate,
@@ -843,8 +922,12 @@
843 922 if ($status === 'confirmed' && $oldStatus !== 'confirmed') {
844 923 \yatra_trigger_booking_confirmed($id, $oldStatus);
845 924 }
846 925
926 + if ($status === 'cancelled' && $oldStatus !== 'cancelled') {
927 + \yatra_trigger_booking_cancelled($id, $oldStatus);
928 + }
929 +
847 930 return [
848 931 'success' => true,
849 932 'message' => sprintf(
850 933 /* translators: %s: new booking status. */
@@ -1179,10 +1262,39 @@
1179 1262 );
1180 1263 }
1181 1264
1182 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 + /**
1183 1295 * Send status change notification
1184 - *
1296 + *
1185 1297 * @param int $bookingId Booking ID
1186 1298 * @param string $oldStatus Previous status
1187 1299 * @param string $newStatus New status
1188 1300 */
@@ -1213,16 +1325,9 @@
1213 1325 return;
1214 1326 }
1215 1327
1216 1328 if ($newStatus === 'confirmed') {
1217 - $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
1218 - $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra');
1219 - $vars['transactional_context'] = 'status_confirmed';
1220 - TransactionalEmailTemplateService::sendIfEnabled(
1221 - TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
1222 - $booking->contact_email,
1223 - $vars
1224 - );
1329 + $this->sendBookingConfirmedEmail($bookingId);
1225 1330
1226 1331 return;
1227 1332 }
1228 1333
@@ -1296,15 +1401,30 @@
1296 1401 if (!$booking) {
1297 1402 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
1298 1403 }
1299 1404
1300 - if (empty($booking->contact_email)) {
1301 - 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')];
1302 1411 }
1303 1412
1304 1413 switch ($emailType) {
1305 1414 case 'confirmation':
1306 - $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 + }
1307 1427 break;
1308 1428
1309 1429 case 'reminder':
1310 1430 $this->sendBookingReminderEmail($booking);
@@ -1309,8 +1429,50 @@
1309 1429 case 'reminder':
1310 1430 $this->sendBookingReminderEmail($booking);
1311 1431 break;
1312 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 +
1313 1475 default:
1314 1476 return ['success' => false, 'message' => __('Unknown email type.', 'yatra')];
1315 1477 }
1316 1478
@@ -1316,8 +1478,32 @@
1316 1478
1317 1479 return [
1318 1480 'success' => true,
1319 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 ?? ''),
1320 1506 ];
1321 1507 }
1322 1508
1323 1509 /**