| @@ -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 | /** |