| @@ -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 | |
| @@ -588,14 +594,36 @@ | ||
| 588 | 594 | if (!empty($data['departure_time']) && is_string($data['departure_time'])) { |
| 589 | 595 | $departureTimeForUpdate = trim($data['departure_time']) !== '' ? trim($data['departure_time']) : null; |
| 590 | 596 | } |
| 591 | 597 | |
| 592 | - if (($dateChanged || $departureTimeForUpdate !== null) && !empty($data['start_date']) && !empty($data['end_date'])) { | |
| 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)) { | |
| 593 | 621 | try { |
| 594 | 622 | $this->departureService->handleBookingDateChange( |
| 595 | 623 | $id, |
| 596 | - $data['start_date'], | |
| 597 | - $data['end_date'], | |
| 624 | + $effectiveStart, | |
| 625 | + $effectiveEnd, | |
| 598 | 626 | $departureTimeForUpdate |
| 599 | 627 | ); |
| 600 | 628 | } catch (\Exception $e) { |
| 601 | 629 | // Log error but don't fail the update |
| @@ -647,8 +675,13 @@ | ||
| 647 | 675 | if ($newStatus === 'confirmed' && $oldStatus !== 'confirmed' |
| 648 | 676 | && function_exists('yatra_trigger_booking_confirmed')) { |
| 649 | 677 | yatra_trigger_booking_confirmed($id, $oldStatus); |
| 650 | 678 | } |
| 679 | + | |
| 680 | + if ($newStatus === 'cancelled' && $oldStatus !== 'cancelled' | |
| 681 | + && function_exists('yatra_trigger_booking_cancelled')) { | |
| 682 | + yatra_trigger_booking_cancelled($id, $oldStatus); | |
| 683 | + } | |
| 651 | 684 | } |
| 652 | 685 | |
| 653 | 686 | // Return the fresh booking so the REST controller's `$result['data']` |
| 654 | 687 | // is defined (previously absent → "Undefined array key data" warning). |
| @@ -847,10 +880,17 @@ | ||
| 847 | 880 | $startDate = $booking->start_date ?? $booking->travel_date; |
| 848 | 881 | $endDate = $booking->end_date ?? $this->calculateEndDate($startDate, (int) $booking->trip_id); |
| 849 | 882 | |
| 850 | 883 | $trip = $this->tripRepository->find((int) $booking->trip_id); |
| 851 | - $maxCapacity = $trip ? ($trip->max_capacity ?? 9999) : 9999; | |
| 852 | - | |
| 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 | + | |
| 853 | 893 | $departure = $this->departureService->findOrCreateForBooking( |
| 854 | 894 | (int) $booking->trip_id, |
| 855 | 895 | $startDate, |
| 856 | 896 | $endDate, |
| @@ -882,8 +922,12 @@ | ||
| 882 | 922 | if ($status === 'confirmed' && $oldStatus !== 'confirmed') { |
| 883 | 923 | \yatra_trigger_booking_confirmed($id, $oldStatus); |
| 884 | 924 | } |
| 885 | 925 | |
| 926 | + if ($status === 'cancelled' && $oldStatus !== 'cancelled') { | |
| 927 | + \yatra_trigger_booking_cancelled($id, $oldStatus); | |
| 928 | + } | |
| 929 | + | |
| 886 | 930 | return [ |
| 887 | 931 | 'success' => true, |
| 888 | 932 | 'message' => sprintf( |
| 889 | 933 | /* translators: %s: new booking status. */ |
| @@ -1218,10 +1262,39 @@ | ||
| 1218 | 1262 | ); |
| 1219 | 1263 | } |
| 1220 | 1264 | |
| 1221 | 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 | + /** | |
| 1222 | 1295 | * Send status change notification |
| 1223 | - * | |
| 1296 | + * | |
| 1224 | 1297 | * @param int $bookingId Booking ID |
| 1225 | 1298 | * @param string $oldStatus Previous status |
| 1226 | 1299 | * @param string $newStatus New status |
| 1227 | 1300 | */ |
| @@ -1252,16 +1325,9 @@ | ||
| 1252 | 1325 | return; |
| 1253 | 1326 | } |
| 1254 | 1327 | |
| 1255 | 1328 | if ($newStatus === 'confirmed') { |
| 1256 | - $vars = TransactionalEmailTemplateService::variablesFromBooking($booking); | |
| 1257 | - $vars['intro_paragraph'] = __('Your booking has been confirmed! Here are your details:', 'yatra'); | |
| 1258 | - $vars['transactional_context'] = 'status_confirmed'; | |
| 1259 | - TransactionalEmailTemplateService::sendIfEnabled( | |
| 1260 | - TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION, | |
| 1261 | - $booking->contact_email, | |
| 1262 | - $vars | |
| 1263 | - ); | |
| 1329 | + $this->sendBookingConfirmedEmail($bookingId); | |
| 1264 | 1330 | |
| 1265 | 1331 | return; |
| 1266 | 1332 | } |
| 1267 | 1333 | |
| @@ -1335,15 +1401,30 @@ | ||
| 1335 | 1401 | if (!$booking) { |
| 1336 | 1402 | return ['success' => false, 'message' => __('Booking not found.', 'yatra')]; |
| 1337 | 1403 | } |
| 1338 | 1404 | |
| 1339 | - if (empty($booking->contact_email)) { | |
| 1340 | - 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')]; | |
| 1341 | 1411 | } |
| 1342 | 1412 | |
| 1343 | 1413 | switch ($emailType) { |
| 1344 | 1414 | case 'confirmation': |
| 1345 | - $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 | + } | |
| 1346 | 1427 | break; |
| 1347 | 1428 | |
| 1348 | 1429 | case 'reminder': |
| 1349 | 1430 | $this->sendBookingReminderEmail($booking); |
| @@ -1348,8 +1429,50 @@ | ||
| 1348 | 1429 | case 'reminder': |
| 1349 | 1430 | $this->sendBookingReminderEmail($booking); |
| 1350 | 1431 | break; |
| 1351 | 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 | + | |
| 1352 | 1475 | default: |
| 1353 | 1476 | return ['success' => false, 'message' => __('Unknown email type.', 'yatra')]; |
| 1354 | 1477 | } |
| 1355 | 1478 | |
| @@ -1355,8 +1478,32 @@ | ||
| 1355 | 1478 | |
| 1356 | 1479 | return [ |
| 1357 | 1480 | 'success' => true, |
| 1358 | 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 ?? ''), | |
| 1359 | 1506 | ]; |
| 1360 | 1507 | } |
| 1361 | 1508 | |
| 1362 | 1509 | /** |