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/Controllers/PaymentGatewayController.php +631 -233 3.0.2.7 → 3.0.16 View file →
@@ -9,9 +9,8 @@
9 9 use WP_Error;
10 10 use Yatra\PaymentGateways\PaymentGatewayRegistry;
11 11 use Yatra\Repositories\BookingRepository;
12 12 use Yatra\Repositories\PaymentRepository;
13 -use Yatra\Repositories\ScheduledPaymentRepository;
14 13 use Yatra\Repositories\TripRepository;
15 14 use Yatra\Helpers\FormatHelper;
16 15 use Yatra\Services\PdfService;
17 16 use Yatra\Services\SettingsService;
@@ -25,9 +24,8 @@
25 24 {
26 25 private PaymentGatewayRegistry $registry;
27 26 private BookingRepository $bookingRepository;
28 27 private PaymentRepository $paymentRepository;
29 - private ScheduledPaymentRepository $scheduledPaymentRepository;
30 28 private TripRepository $tripRepository;
31 29
32 30 public function __construct()
33 31 {
@@ -33,9 +31,8 @@
33 31 {
34 32 $this->registry = PaymentGatewayRegistry::getInstance();
35 33 $this->bookingRepository = new BookingRepository();
36 34 $this->paymentRepository = new PaymentRepository();
37 - $this->scheduledPaymentRepository = new ScheduledPaymentRepository();
38 35 $this->tripRepository = new TripRepository();
39 36 }
40 37
41 38 public function register_routes(): void
@@ -147,16 +144,31 @@
147 144 'callback' => [$this, 'download_voucher'],
148 145 'permission_callback' => '__return_true', // Auth checked inside callback
149 146 ],
150 147 ]);
148 +
149 + // Download a pro-forma invoice for a booking that has no payment yet
150 + // (offline gateways, e.g. Bank Transfer). Includes payment instructions
151 + // so the customer knows how to pay. Auth checked inside the callback.
152 + register_rest_route($namespace, '/booking/(?P<booking_id>[\d]+)/invoice', [
153 + [
154 + 'methods' => \WP_REST_Server::READABLE,
155 + 'callback' => [$this, 'download_booking_invoice'],
156 + 'permission_callback' => '__return_true',
157 + ],
158 + ]);
151 159 }
152 160
153 161 /**
154 - * Check admin permission
162 + * Payment gateway config — critical-sensitivity cap. By default
163 + * only the Owner role holds `yatra_manage_payment_gateways`
164 + * (Manager doesn't, deliberately — gateway keys are among the
165 + * most sensitive credentials on the site). WP admins pass via
166 + * the Team module's admin-fallback filter.
155 167 */
156 168 public function check_admin_permission(): bool
157 169 {
158 - return current_user_can('manage_options');
170 + return current_user_can('yatra_manage_payment_gateways');
159 171 }
160 172
161 173 public function check_customer_permission(): bool
162 174 {
@@ -202,8 +214,22 @@
202 214
203 215 $customerEmail = $booking->contact_email ?? ($booking->customer_email ?? '');
204 216 $customerName = trim(($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''));
205 217
218 + // Append `balance=paid` to the gateway's return URL so the booking-confirmation
219 + // template can render a "balance just paid" banner instead of the generic "booking
220 + // confirmed" copy. Same canonical URL — the flag only switches contextual content.
221 + $confirmationUrl = $this->getConfirmationUrl($booking->reference ?? (string) $bookingId);
222 + $confirmationUrl = add_query_arg('balance', 'paid', $confirmationUrl);
223 +
224 + // Customer-account base is configurable under Settings → Permalink. Don't
225 + // hardcode `/my-account` — that breaks for sites that have customised the slug.
226 + $accountUrl = home_url('/' . SettingsService::getAccountBase());
227 + $cancelUrl = add_query_arg(
228 + ['tab' => 'payments', 'payment' => 'cancelled'],
229 + $accountUrl
230 + );
231 +
206 232 $paymentData = [
207 233 'amount' => $remainingAmount,
208 234 'currency' => $booking->currency ?? get_option('yatra_currency', 'USD'),
209 235 'booking_id' => $bookingId,
@@ -208,11 +234,15 @@
208 234 'currency' => $booking->currency ?? get_option('yatra_currency', 'USD'),
209 235 'booking_id' => $bookingId,
210 236 'customer_email' => $customerEmail,
211 237 'customer_name' => $customerName ?: $customerEmail,
212 - 'return_url' => $this->getConfirmationUrl($booking->reference ?? (string) $bookingId),
213 - 'description' => sprintf(__('Remaining balance for Booking #%s', 'yatra'), $booking->reference ?? $bookingId),
214 - 'cancel_url' => home_url('/my-account?tab=payments&payment=cancelled'),
238 + 'return_url' => $confirmationUrl,
239 + 'description' => sprintf(
240 + /* translators: %s: booking reference. */
241 + __('Remaining balance for Booking #%s', 'yatra'),
242 + $booking->reference ?? $bookingId
243 + ),
244 + 'cancel_url' => $cancelUrl,
215 245 ];
216 246
217 247 $result = $this->registry->processPayment($method, $paymentData);
218 248
@@ -309,15 +339,45 @@
309 339 ]);
310 340 }
311 341
312 342 /**
313 - * Get available gateways for checkout
343 + * Get available gateways for checkout.
344 + *
345 + * For the *remaining-balance* checkout (when `yatra_has_remaining_session()` is
346 + * true OR the request explicitly carries `?context=remaining`), offline gateways
347 + * are filtered out — Pay Later / Bank Transfer don't actually collect money, so
348 + * picking them to "settle a balance" leaves the booking still unpaid and the
349 + * customer thinking they finished the flow. Filterable via
350 + * `yatra_remaining_payment_allowed_gateways` if a site needs custom behaviour.
314 351 */
315 352 public function get_available_gateways(WP_REST_Request $request): WP_REST_Response
316 353 {
354 + $gateways = $this->registry->getForCheckout();
355 +
356 + $context = sanitize_key((string) ($request->get_param('context') ?? ''));
357 + $isRemainingFlow = $context === 'remaining'
358 + || (function_exists('yatra_has_remaining_session') && yatra_has_remaining_session());
359 +
360 + if ($isRemainingFlow) {
361 + $gateways = array_values(array_filter($gateways, static function ($gw) {
362 + return empty($gw['is_offline']);
363 + }));
364 +
365 + /**
366 + * Filter the gateway list shown in the remaining-balance checkout.
367 + *
368 + * Default: every offline gateway (Pay Later, Bank Transfer, etc.) is
369 + * removed so the customer can only pick a real-money method.
370 + *
371 + * @param array $gateways Gateway entries (id, title, is_offline, …).
372 + */
373 + $gateways = apply_filters('yatra_remaining_payment_allowed_gateways', $gateways);
374 + }
375 +
317 376 return new WP_REST_Response([
318 - 'gateways' => $this->registry->getForCheckout(),
377 + 'gateways' => $gateways,
319 378 'currency' => get_option('yatra_currency', 'USD'),
379 + 'context' => $isRemainingFlow ? 'remaining' : 'initial',
320 380 ], 200);
321 381 }
322 382
323 383 /**
@@ -367,18 +427,54 @@
367 427 'customer_name' => sanitize_text_field($request->get_param('customer_name')),
368 428 'return_url' => esc_url_raw($request->get_param('return_url')),
369 429 ];
370 430
371 - // Enrich payment data with booking context (reference, trip title, cancel URL)
431 + // Enrich payment data with booking context (reference, trip title, cancel URL).
432 + // SECURITY: when a booking_id is supplied, the authoritative amount/currency must come
433 + // from the database row, NOT from the client. Otherwise an attacker can pay $1 for a
434 + // $1000 trip by tampering with the JSON body.
372 435 if ($paymentData['booking_id'] > 0) {
373 436 $booking = $this->bookingRepository->find($paymentData['booking_id']);
374 - if ($booking) {
375 - $paymentData['reference'] = $booking->reference ?? '';
376 - $paymentData['trip_title'] = $booking->trip_title ?? '';
377 - if (empty($paymentData['trip_id'])) {
378 - $paymentData['trip_id'] = (int) ($booking->trip_id ?? 0);
437 + if (!$booking) {
438 + return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
439 + }
440 +
441 + // If the booking is owned by a registered user, only that user (or an admin) may pay it.
442 + // Guest bookings (user_id = 0) remain payable without auth — the booking session controls access.
443 + $bookingUserId = (int) ($booking->user_id ?? 0);
444 + if ($bookingUserId > 0) {
445 + $currentUserId = (int) get_current_user_id();
446 + if ($currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
447 + return new WP_Error('forbidden', __('You do not have permission to pay for this booking.', 'yatra'), ['status' => 403]);
379 448 }
380 449 }
450 +
451 + // Reject already-paid bookings to prevent duplicate intents.
452 + if (isset($booking->payment_status) && $booking->payment_status === 'paid') {
453 + return new WP_Error('already_paid', __('This booking is already fully paid.', 'yatra'), ['status' => 400]);
454 + }
455 +
456 + // Server-authoritative amount/currency. Use amount_due, falling back to total - paid for older rows.
457 + $serverAmount = (float) ($booking->amount_due ?? ($booking->total_amount - $booking->amount_paid));
458 + $serverCurrency = (string) ($booking->currency ?? get_option('yatra_currency', 'USD'));
459 +
460 + if ($serverAmount <= 0) {
461 + return new WP_Error('no_balance_due', __('This booking has no outstanding balance.', 'yatra'), ['status' => 400]);
462 + }
463 +
464 + // Tolerate sub-cent rounding drift only.
465 + if (abs($paymentData['amount'] - $serverAmount) > 0.01) {
466 + $this->log_amount_mismatch((int) $booking->id, $paymentData['amount'], $serverAmount);
467 + }
468 +
469 + // Always overwrite with server values regardless of what the client sent.
470 + $paymentData['amount'] = $serverAmount;
471 + $paymentData['currency'] = $serverCurrency;
472 + $paymentData['reference'] = $booking->reference ?? '';
473 + $paymentData['trip_title'] = $booking->trip_title ?? '';
474 + if (empty($paymentData['trip_id'])) {
475 + $paymentData['trip_id'] = (int) ($booking->trip_id ?? 0);
476 + }
381 477 }
382 478
383 479 if (empty($paymentData['return_url'])) {
384 480 $reference = $paymentData['reference'] ?? (string) $paymentData['booking_id'];
@@ -385,9 +481,12 @@
385 481 $paymentData['return_url'] = add_query_arg('payment', 'success', $this->getConfirmationUrl($reference));
386 482 }
387 483
388 484 $cancelParam = esc_url_raw($request->get_param('cancel_url'));
389 - $paymentData['cancel_url'] = $cancelParam ?: home_url('/book/?payment=cancelled&ref=' . ($paymentData['reference'] ?? $paymentData['booking_id']));
485 + // Fall back to the booking-confirmation page (always a resolvable route) rather
486 + // than `home_url('/book/?...')`, which 404s under a custom booking base/page.
487 + $cancelReference = (string) ($paymentData['reference'] ?? $paymentData['booking_id']);
488 + $paymentData['cancel_url'] = $cancelParam ?: add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelReference));
390 489
391 490 if ($paymentData['amount'] <= 0) {
392 491 return new WP_Error('invalid_amount', __('Invalid payment amount', 'yatra'), ['status' => 400]);
393 492 }
@@ -407,8 +506,30 @@
407 506 return yatra_get_booking_confirmation_url($reference);
408 507 }
409 508
410 509 /**
510 + * Record an attempted payment-amount mismatch (client sent X, server expects Y).
511 + * The transaction itself is forced to the server amount; this exists for fraud monitoring.
512 + */
513 + private function log_amount_mismatch(int $bookingId, float $clientAmount, float $serverAmount): void
514 + {
515 + if (defined('WP_DEBUG') && WP_DEBUG) {
516 + error_log(sprintf(
517 + '[Yatra] Payment amount mismatch for booking %d: client=%.4f server=%.4f',
518 + $bookingId,
519 + $clientAmount,
520 + $serverAmount
521 + ));
522 + }
523 +
524 + /**
525 + * Fires when a client-supplied payment amount disagrees with the server-side booking amount.
526 + * Useful for fraud-monitoring integrations.
527 + */
528 + do_action('yatra_payment_amount_mismatch', $bookingId, $clientAmount, $serverAmount);
529 + }
530 +
531 + /**
411 532 * Confirm payment
412 533 */
413 534 public function confirm_payment(WP_REST_Request $request)
414 535 {
@@ -421,8 +542,63 @@
421 542 if (!$gateway) {
422 543 return new WP_Error('invalid_gateway', __('Gateway not found', 'yatra'), ['status' => 404]);
423 544 }
424 545
546 + // Offline gateways (Bank Transfer, Pay Later) settle out of band and take
547 + // no money at checkout. They must NEVER be auto-completed through this
548 + // endpoint — doing so marks the booking paid + records a "completed"
549 + // payment before any funds have arrived. Confirmation is a manual admin
550 + // action once the operator sees the money. Guard here as well as in the
551 + // gateways' verifyPayment() so a future offline gateway can't regress.
552 + if ($gateway->isOffline()) {
553 + return new WP_Error(
554 + 'offline_gateway_manual',
555 + __('This payment method is settled manually and cannot be confirmed automatically.', 'yatra'),
556 + ['status' => 400]
557 + );
558 + }
559 +
560 + if ($bookingId <= 0 || $transactionId === '') {
561 + return new WP_Error('invalid_request', __('booking_id and transaction_id are required.', 'yatra'), ['status' => 400]);
562 + }
563 +
564 + // Resolve the booking up front so we can enforce ownership BEFORE confirming a charge against it.
565 + // Without this check, an anonymous attacker could mark booking B as paid by replaying a successful
566 + // transaction_id that actually belongs to booking A.
567 + $booking = $this->bookingRepository->find($bookingId);
568 + if (!$booking) {
569 + return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
570 + }
571 +
572 + $bookingUserId = (int) ($booking->user_id ?? 0);
573 + if ($bookingUserId > 0) {
574 + $currentUserId = (int) get_current_user_id();
575 + if ($currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
576 + return new WP_Error('forbidden', __('You do not have permission to confirm this payment.', 'yatra'), ['status' => 403]);
577 + }
578 + }
579 +
580 + // Idempotency: if we have already recorded this transaction, return the cached verification result
581 + // without re-applying the payment. Prevents duplicate ledger rows and double-confirmed bookings
582 + // when the user reloads the confirmation page.
583 + $existing = $this->paymentRepository->findByTransactionId($transactionId);
584 + if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) {
585 + return new WP_REST_Response([
586 + 'success' => true,
587 + 'status' => $existing->status ?? 'completed',
588 + 'amount' => (float) ($existing->amount ?? 0),
589 + 'currency' => $existing->currency ?? null,
590 + 'transaction_id' => $transactionId,
591 + 'idempotent' => true,
592 + ], 200);
593 + }
594 +
595 + // If a payment with this transaction id is already attached to a DIFFERENT booking, refuse —
596 + // someone is trying to reuse a stranger's transaction to pay their own booking.
597 + if ($existing && (int) ($existing->booking_id ?? 0) !== $bookingId) {
598 + return new WP_Error('transaction_mismatch', __('Transaction does not belong to this booking.', 'yatra'), ['status' => 409]);
599 + }
600 +
425 601 $result = $gateway->verifyPayment($transactionId);
426 602
427 603 if ($result['success']) {
428 604 // Get customer and payment method from result
@@ -428,16 +604,23 @@
428 604 // Get customer and payment method from result
429 605 $customerId = $result['customer_id'] ?? null;
430 606 $paymentMethodId = $result['payment_method_id'] ?? $result['token_id'] ?? $result['vault_id'] ?? null;
431 607
608 + $passForSchedule = (bool) apply_filters(
609 + 'yatra_pass_gateway_ids_for_scheduled_payments',
610 + $saveCard,
611 + $result,
612 + $bookingId
613 + );
614 +
432 615 $this->handle_successful_payment(
433 - $bookingId,
434 - $gatewayId,
435 - $transactionId,
436 - $result['amount'] ?? null,
616 + $bookingId,
617 + $gatewayId,
618 + $transactionId,
619 + $result['amount'] ?? null,
437 620 $result['currency'] ?? null,
438 - $saveCard ? $customerId : null,
439 - $saveCard ? $paymentMethodId : null
621 + ($saveCard || $passForSchedule) ? $customerId : null,
622 + ($saveCard || $passForSchedule) ? $paymentMethodId : null
440 623 );
441 624 }
442 625
443 626 return new WP_REST_Response($result, 200);
@@ -480,10 +663,19 @@
480 663 wp_redirect(home_url('/booking-failed/'));
481 664 exit;
482 665 }
483 666
667 + // Offline gateways never redirect here, and must never be auto-completed:
668 + // they settle out of band and are confirmed manually by the operator.
669 + // Bounce a spoofed `?status=success` callback to the confirmation page
670 + // (still pending) rather than recording a payment that never happened.
671 + if ($gateway->isOffline()) {
672 + wp_redirect(yatra_get_booking_confirmation_url($bookingId > 0 ? (string) $bookingId : ''));
673 + exit;
674 + }
675 +
484 676 // Get transaction ID from request (varies by gateway)
485 - $transactionId = $request->get_param('refId')
677 + $transactionId = $request->get_param('refId')
486 678 ?? $request->get_param('pidx')
487 679 ?? $request->get_param('transaction_id')
488 680 ?? '';
489 681
@@ -502,13 +694,23 @@
502 694 }
503 695
504 696 /**
505 697 * Get payment status
698 + *
699 + * Endpoint is public (`__return_true` permission) so guest checkouts can poll. Authorisation
700 + * is enforced inline: registered-user bookings require the owning user (or an admin); guest
701 + * bookings additionally require a matching short-lived booking_token transient so a stranger
702 + * can't enumerate booking IDs to harvest payment metadata.
506 703 */
507 704 public function get_payment_status(WP_REST_Request $request)
508 705 {
509 706 $bookingId = (int) $request->get_param('booking_id');
707 + $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? ''));
510 708
709 + if ($bookingId <= 0) {
710 + return new WP_Error('invalid_booking', __('Invalid booking ID.', 'yatra'), ['status' => 400]);
711 + }
712 +
511 713 $payment = $this->paymentRepository->findLatestByBookingId($bookingId);
512 714
513 715 if (!$payment) {
514 716 return new WP_Error('payment_not_found', __('Payment not found', 'yatra'), ['status' => 404]);
@@ -513,8 +715,33 @@
513 715 if (!$payment) {
514 716 return new WP_Error('payment_not_found', __('Payment not found', 'yatra'), ['status' => 404]);
515 717 }
516 718
719 + $booking = $this->bookingRepository->find($bookingId);
720 + $bookingUserId = $booking ? (int) ($booking->user_id ?? 0) : 0;
721 + $currentUserId = (int) get_current_user_id();
722 + $authorised = false;
723 +
724 + if (current_user_can('manage_options')) {
725 + $authorised = true;
726 + } elseif ($bookingUserId > 0 && $currentUserId === $bookingUserId) {
727 + $authorised = true;
728 + } elseif ($bookingUserId === 0 && $bookingToken !== '') {
729 + // Guest booking: require the booking-session transient to prove the requester is the
730 + // browser that started this checkout.
731 + $session = get_transient($bookingToken);
732 + if (is_array($session) && (int) ($session['booking_id'] ?? 0) === $bookingId) {
733 + $authorised = true;
734 + }
735 + }
736 +
737 + if (!$authorised) {
738 + if ($currentUserId > 0) {
739 + return new WP_Error('forbidden', __('You do not have permission to view this payment.', 'yatra'), ['status' => 403]);
740 + }
741 + return new WP_Error('unauthorized', __('Authentication required.', 'yatra'), ['status' => 401]);
742 + }
743 +
517 744 return new WP_REST_Response([
518 745 'status' => $payment->status,
519 746 'amount' => (float) $payment->amount,
520 747 'currency' => $payment->currency,
@@ -550,8 +777,18 @@
550 777
551 778 $paid_amount = $amount ?? (float) $booking->amount_due;
552 779 $payment_currency = $currency ?? $booking->currency;
553 780
781 + // Idempotency guard: skip if we have already recorded this gateway transaction for this booking.
782 + // Prevents double-applied payments when both confirm_payment and the gateway's own return-handler
783 + // (or a webhook) fire for the same charge.
784 + if ($transactionId !== '') {
785 + $existing = $this->paymentRepository->findByTransactionId($transactionId);
786 + if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) {
787 + return;
788 + }
789 + }
790 +
554 791 $payment_data = [
555 792 'booking_id' => $bookingId,
556 793 'gateway' => $gateway,
557 794 'transaction_id' => $transactionId,
@@ -573,26 +810,28 @@
573 810 if ($new_amount_due > 0) {
574 811 $payment_status = 'partial';
575 812 }
576 813
814 + $previousBookingStatus = (string) ($booking->status ?? 'pending');
815 +
816 + // Only auto-confirm when "Auto-Confirm Bookings" is on; otherwise the
817 + // booking stays pending for the operator to confirm manually, regardless
818 + // of a successful (full or partial) payment.
819 + $should_confirm = \yatra_should_confirm_booking_on_payment($new_amount_due <= 0, $bookingId);
820 +
577 821 // Update booking
578 - $this->bookingRepository->update($bookingId, [
822 + $booking_update = [
579 823 'amount_paid' => $new_amount_paid,
580 824 'amount_due' => $new_amount_due,
581 825 'payment_status' => $payment_status,
582 - 'status' => 'confirmed',
583 - ]);
826 + ];
827 + if ($should_confirm) {
828 + $booking_update['status'] = 'confirmed';
829 + }
830 + $this->bookingRepository->update($bookingId, $booking_update);
584 831
585 - // Handle scheduled payments for remaining balance
586 - if ($new_amount_due > 0 && $customerId && $paymentMethodId) {
587 - $this->createScheduledPaymentsForBooking(
588 - $bookingId,
589 - $gateway,
590 - $customerId,
591 - $paymentMethodId,
592 - $new_amount_due,
593 - $payment_currency
594 - );
832 + if ($should_confirm) {
833 + \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus, true);
595 834 }
596 835
597 836 // Clear remaining payment session if this was a remaining payment
598 837 if (function_exists('yatra_has_remaining_session') && yatra_has_remaining_session()) {
@@ -607,103 +846,8 @@
607 846 ]);
608 847 }
609 848
610 849 /**
611 - * Create scheduled payments for remaining balance
612 - */
613 - private function createScheduledPaymentsForBooking(
614 - int $bookingId,
615 - string $gateway,
616 - string $customerId,
617 - string $paymentMethodId,
618 - float $remainingAmount,
619 - string $currency
620 - ): void {
621 - // Get scheduled payment settings
622 - $settings = \Yatra\Services\SettingsService::getAll();
623 -
624 - // Check if auto-scheduled payments is enabled
625 - if (empty($settings['enable_scheduled_payments'])) {
626 - return;
627 - }
628 -
629 - // Save the payment token first
630 - $tokenId = $this->savePaymentToken($bookingId, $gateway, $customerId, $paymentMethodId);
631 -
632 - if (!$tokenId) {
633 - return;
634 - }
635 -
636 - // Get schedule configuration from settings
637 - $schedule = [
638 - 'type' => $settings['scheduled_payment_type'] ?? 'single', // single, installments
639 - 'days_until' => (int) ($settings['scheduled_payment_days'] ?? 15),
640 - 'installments' => (int) ($settings['scheduled_payment_installments'] ?? 1),
641 - 'interval_days' => (int) ($settings['scheduled_payment_interval'] ?? 30),
642 - ];
643 -
644 - // Create scheduled payments
645 - \Yatra\Services\ScheduledPaymentService::createScheduledPayments(
646 - $bookingId,
647 - $gateway,
648 - $customerId,
649 - $tokenId,
650 - $remainingAmount,
651 - $currency,
652 - $schedule
653 - );
654 - }
655 -
656 - /**
657 - * Save payment token for future charges
658 - */
659 - private function savePaymentToken(
660 - int $bookingId,
661 - string $gateway,
662 - string $customerId,
663 - string $paymentMethodId
664 - ): ?int {
665 - // Get booking for customer info
666 - $booking = $this->bookingRepository->find($bookingId);
667 -
668 - if (!$booking) {
669 - return null;
670 - }
671 -
672 - $userId = get_current_user_id() ?: 0;
673 -
674 - // Get payment method details from gateway
675 - $gatewayInstance = $this->registry->get($gateway);
676 - $cardInfo = [];
677 -
678 - if ($gatewayInstance) {
679 - $methods = $gatewayInstance->getPaymentMethods($customerId);
680 - foreach ($methods as $method) {
681 - if ($method['id'] === $paymentMethodId) {
682 - $cardInfo = $method;
683 - break;
684 - }
685 - }
686 - }
687 -
688 - // Create token via repository
689 - $tokenId = $this->scheduledPaymentRepository->createPaymentToken([
690 - 'customer_id' => $userId,
691 - 'user_id' => $userId,
692 - 'gateway' => $gateway,
693 - 'token' => $paymentMethodId,
694 - 'payment_method_id' => $paymentMethodId,
695 - 'card_brand' => $cardInfo['brand'] ?? null,
696 - 'card_last4' => $cardInfo['last4'] ?? null,
697 - 'card_exp_month' => $cardInfo['exp_month'] ?? null,
698 - 'card_exp_year' => $cardInfo['exp_year'] ?? null,
699 - 'is_default' => 1,
700 - ]);
701 -
702 - return $tokenId ?: null;
703 - }
704 -
705 - /**
706 850 * Download invoice PDF for a payment
707 851 */
708 852 public function download_invoice(WP_REST_Request $request)
709 853 {
@@ -709,8 +853,10 @@
709 853 {
710 854 $paymentId = (int) $request->get_param('payment_id');
711 855 $isPreview = $request->get_param('preview') === '1';
712 856 $isDownload = $request->get_param('download') === '1';
857 + $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? ''));
858 + $invoiceToken = sanitize_text_field((string) ($request->get_param('invoice_token') ?? ''));
713 859
714 860 if ($paymentId <= 0) {
715 861 return new WP_Error('invalid_payment', __('Invalid payment ID.', 'yatra'), ['status' => 400]);
716 862 }
@@ -721,21 +867,47 @@
721 867 if (!$payment) {
722 868 return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]);
723 869 }
724 870
725 - // Verify user is logged in and owns this payment (or is admin)
726 - $currentUserId = get_current_user_id();
871 + // Authorisation:
872 + // 1. Staff can always access (no further checks): a WP administrator,
873 + // or a user holding Yatra's yatra_view_bookings capability, which is
874 + // the same audience that already sees every booking in the list.
875 + // 2. Logged-in owner of the booking can access.
876 + // 3. Anyone with a valid signed `invoice_token` (HMAC) can access — used on the
877 + // booking-confirmation page so guest checkouts and post-session views work.
878 + // 4. Legacy guest path: `booking_token` (active checkout transient) — kept for BC.
879 + $currentUserId = (int) get_current_user_id();
727 880 $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0);
728 -
729 - // Must be logged in
730 - if (!$currentUserId) {
881 + $paymentBookingId = (int) ($payment->booking_id ?? 0);
882 + $isAdmin = current_user_can('manage_options') || current_user_can('yatra_view_bookings');
883 + $authorised = false;
884 +
885 + if ($isAdmin) {
886 + $authorised = true;
887 + } elseif ($currentUserId && $bookingUserId && $currentUserId === $bookingUserId) {
888 + $authorised = true;
889 + } elseif ($invoiceToken !== '' && self::verifyInvoiceToken($invoiceToken, (int) $payment->id, $paymentBookingId)) {
890 + $authorised = true;
891 + } elseif ($bookingToken !== '') {
892 + $guestEnabled = (bool) SettingsService::get('allow_guest_checkout', true);
893 + if ($guestEnabled) {
894 + $session = get_transient($bookingToken);
895 + if (is_array($session)) {
896 + $sessionBookingId = (int) ($session['booking_id'] ?? 0);
897 + if ($sessionBookingId > 0 && $paymentBookingId > 0 && $sessionBookingId === $paymentBookingId) {
898 + $authorised = true;
899 + }
900 + }
901 + }
902 + }
903 +
904 + if (!$authorised) {
905 + if ($currentUserId) {
906 + return new WP_Error('forbidden', __('You do not have permission to access this invoice.', 'yatra'), ['status' => 403]);
907 + }
731 908 return new WP_Error('unauthorized', __('You must be logged in to download invoices.', 'yatra'), ['status' => 401]);
732 909 }
733 -
734 - // Must own the booking or be admin
735 - if ($bookingUserId && $currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
736 - return new WP_Error('forbidden', __('You do not have permission to access this invoice.', 'yatra'), ['status' => 403]);
737 - }
738 910
739 911 // Get trip details if available
740 912 $trip = null;
741 913 if (!empty($payment->trip_id)) {
@@ -779,9 +951,11 @@
779 951 $tax_amount += (float) ($tax['amount'] ?? 0);
780 952 $tax_breakdown[] = [
781 953 'name' => $tax['name'] ?? 'Tax',
782 954 'rate' => $tax['rate'] ?? 0,
783 - 'amount' => $tax['amount'] ?? 0
955 + // Pre-formatted like every other invoice figure, so the tax
956 + // rows honour the configured separators and symbol position.
957 + 'amount' => yatra_format_price((float) ($tax['amount'] ?? 0), $currency, false)
784 958 ];
785 959 }
786 960 // Adjust subtotal for tax-exclusive pricing
787 961 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
@@ -792,9 +966,9 @@
792 966 $tax_amount = (float) $payment->tax_amount;
793 967 $tax_breakdown[] = [
794 968 'name' => __('Tax', 'yatra'),
795 969 'rate' => (float) ($payment->tax_rate ?? 0),
796 - 'amount' => $tax_amount
970 + 'amount' => yatra_format_price((float) $tax_amount, $currency, false)
797 971 ];
798 972 // Adjust subtotal for tax-exclusive pricing
799 973 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
800 974 $subtotal = (float) ($payment->subtotal ?? $subtotal);
@@ -805,28 +979,42 @@
805 979
806 980 $templateData = [
807 981 'company_name' => $companyName,
808 982 'company_address' => $companyAddress,
983 + 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(),
809 984 'company_email' => $companyEmail,
810 985 'company_phone' => $companyPhone,
811 986 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
812 987 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
813 - 'payment_ref' => $payment->reference ?? '',
988 + 'customer_address_lines' => FormatHelper::customerAddressLines($payment),
989 + // The booking_payments table has no `reference` column — the payment
990 + // reference is a derived value. Mirror PaymentService::formatPayment
991 + // (`PAY-%06d`, the same string the React account page shows) so the
992 + // invoice's "Invoice #" is populated and consistent, instead of blank.
993 + // A real stored reference (if a future join ever provides one) still wins.
994 + 'payment_ref' => (isset($payment->reference) && (string) $payment->reference !== '')
995 + ? (string) $payment->reference
996 + : sprintf('PAY-%06d', (int) ($payment->id ?? 0)),
814 997 'payment_date' => $paymentDate,
815 998 'payment_status' => ucfirst($payment->status ?? 'paid'),
816 999 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['paid', 'completed', 'success'], true) ? 'paid' : 'pending',
817 1000 'trip_title' => $trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra'),
818 - 'payment_method' => ucfirst($payment->gateway ?? $payment->payment_method ?? 'Online'),
819 - 'booking_ref' => $payment->booking_reference ?? $payment->booking_number ?? '',
1001 + 'payment_method' => $this->gatewayLabel(
1002 + $payment->gateway ?? $payment->payment_method ?? null,
1003 + __('Online', 'yatra')
1004 + ),
1005 + // Booking-only fallback chain (never a payment identifier) so the
1006 + // invoice number always resolves to the booking reference.
1007 + 'booking_ref' => $payment->booking_reference ?? $payment->booking_number ?? (string) ($payment->booking_id ?? ''),
820 1008 'travel_date' => $travelDate,
821 1009 'currency_symbol' => $currencySymbol,
822 - 'amount' => number_format((float) ($payment->amount ?? 0), 2),
823 - 'booking_total' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
824 - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
825 - 'amount_due' => number_format((float) ($payment->booking_amount_due ?? 0), 2),
1010 + 'amount' => yatra_format_price((float) ($payment->amount ?? 0), $currency, false),
1011 + 'booking_total' => yatra_format_price((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), $currency, false),
1012 + 'amount_paid' => yatra_format_price((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), $currency, false),
1013 + 'amount_due' => yatra_format_price((float) ($payment->booking_amount_due ?? 0), $currency, false),
826 1014 'tax_breakdown' => $tax_breakdown,
827 - 'tax_amount' => number_format($tax_amount, 2),
828 - 'subtotal' => number_format($subtotal, 2),
1015 + 'tax_amount' => yatra_format_price((float) $tax_amount, $currency, false),
1016 + 'subtotal' => yatra_format_price((float) $subtotal, $currency, false),
829 1017 ];
830 1018
831 1019 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/invoice.php', $templateData, [
832 1020 'paper' => 'A4',
@@ -848,8 +1036,144 @@
848 1036 }
849 1037 }
850 1038
851 1039 /**
1040 + * Customer-facing label for a gateway id on a document.
1041 + *
1042 + * Uses the same title the checkout shows (operator's custom title, else the
1043 + * gateway's translated one). Falls back to the prettified id — the previous
1044 + * behaviour — when the gateway is not registered any more, so an invoice for
1045 + * a payment taken through a since-removed gateway still reads sensibly.
1046 + */
1047 + private function gatewayLabel(?string $gatewayId, string $fallback = 'Online'): string
1048 + {
1049 + return function_exists('yatra_payment_gateway_label')
1050 + ? yatra_payment_gateway_label($gatewayId, $fallback)
1051 + : ($gatewayId ? ucwords(str_replace(['_', '-'], ' ', $gatewayId)) : $fallback);
1052 + }
1053 +
1054 + /**
1055 + * Download a PRO-FORMA invoice for a booking that has no payment yet
1056 + * (offline gateways such as Bank Transfer). Shows the amount due and any
1057 + * gateway-supplied payment instructions (via yatra_invoice_payment_instructions)
1058 + * so the customer knows how to pay. Renders the same pdf/invoice.php template.
1059 + */
1060 + public function download_booking_invoice(WP_REST_Request $request)
1061 + {
1062 + $bookingId = (int) $request->get_param('booking_id');
1063 + $isPreview = $request->get_param('preview') === '1';
1064 + $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? ''));
1065 + $invoiceToken = sanitize_text_field((string) ($request->get_param('invoice_token') ?? ''));
1066 +
1067 + if ($bookingId <= 0) {
1068 + return new WP_Error('invalid_booking', __('Invalid booking ID.', 'yatra'), ['status' => 400]);
1069 + }
1070 +
1071 + $bookingRepository = new \Yatra\Repositories\BookingRepository();
1072 + $booking = $bookingRepository->find($bookingId);
1073 + if (!$booking) {
1074 + return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
1075 + }
1076 +
1077 + // Authorisation mirrors download_invoice: staff -> owner -> signed
1078 + // booking-scoped invoice_token (paymentId 0) -> guest booking_token.
1079 + // Staff means a WP admin OR a user holding Yatra's booking-view
1080 + // capability, so Pro Team roles (which deliberately don't carry
1081 + // manage_options) can use the admin "Download invoice" action.
1082 + $currentUserId = (int) get_current_user_id();
1083 + $bookingUserId = (int) ($booking->user_id ?? 0);
1084 + $authorised = false;
1085 + if (current_user_can('manage_options') || current_user_can('yatra_view_bookings')) {
1086 + $authorised = true;
1087 + } elseif ($currentUserId && $bookingUserId && $currentUserId === $bookingUserId) {
1088 + $authorised = true;
1089 + } elseif ($invoiceToken !== '' && self::verifyInvoiceToken($invoiceToken, 0, $bookingId)) {
1090 + $authorised = true;
1091 + } elseif ($bookingToken !== '' && (bool) SettingsService::get('allow_guest_checkout', true)) {
1092 + $session = get_transient($bookingToken);
1093 + if (is_array($session) && (int) ($session['booking_id'] ?? 0) === $bookingId) {
1094 + $authorised = true;
1095 + }
1096 + }
1097 + if (!$authorised) {
1098 + return $currentUserId
1099 + ? new WP_Error('forbidden', __('You do not have permission to access this invoice.', 'yatra'), ['status' => 403])
1100 + : new WP_Error('unauthorized', __('You must be logged in to download invoices.', 'yatra'), ['status' => 401]);
1101 + }
1102 +
1103 + $pdfService = new PdfService();
1104 + if (!$pdfService->isAvailable()) {
1105 + return new WP_Error('pdf_engine_missing', __('Invoice PDF generator is not installed. Please run composer install to install dompdf/dompdf.', 'yatra'), ['status' => 500]);
1106 + }
1107 +
1108 + $trip = !empty($booking->trip_id) ? $this->tripRepository->find((int) $booking->trip_id) : null;
1109 +
1110 + $currency = SettingsService::getCurrency();
1111 + $currencySymbol = FormatHelper::getCurrencySymbol($currency);
1112 + $bookingRef = (string) ($booking->reference ?? $booking->booking_number ?? (string) $bookingId);
1113 + $filename = 'Invoice #' . $bookingRef . '.pdf';
1114 + $travelDate = !empty($booking->travel_date) ? date_i18n(get_option('date_format'), strtotime((string) $booking->travel_date)) : '';
1115 +
1116 + $total = (float) ($booking->total_amount ?? 0);
1117 + $paid = (float) ($booking->amount_paid ?? 0);
1118 + $due = (float) ($booking->amount_due ?? max(0.0, $total - $paid));
1119 +
1120 + // Gateway-supplied payment instructions (Bank Transfer fills this in Pro).
1121 + $paymentInstructions = apply_filters('yatra_invoice_payment_instructions', [], $booking);
1122 +
1123 + $templateData = [
1124 + 'company_name' => SettingsService::get('company_name', get_bloginfo('name')),
1125 + 'company_address' => SettingsService::get('company_address', ''),
1126 + 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(),
1127 + 'company_email' => SettingsService::get('company_email', get_option('admin_email')),
1128 + 'company_phone' => SettingsService::get('company_phone', ''),
1129 + 'customer_name' => trim(($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? '')) ?: __('Customer', 'yatra'),
1130 + 'customer_email' => $booking->contact_email ?? '',
1131 + 'customer_address_lines' => FormatHelper::customerAddressLines($booking),
1132 + 'payment_ref' => $bookingRef,
1133 + 'payment_date' => !empty($booking->created_at) ? date_i18n(get_option('date_format'), strtotime((string) $booking->created_at)) : '',
1134 + // Reflect the booking's real payment state rather than a fixed
1135 + // "Payment Pending" — a deposit-paid booking is Partially Paid.
1136 + 'payment_status' => $due <= 0.0
1137 + ? __('Paid', 'yatra')
1138 + : ($paid > 0.0 ? __('Partially Paid', 'yatra') : __('Payment Pending', 'yatra')),
1139 + 'status_class' => $due <= 0.0 ? 'paid' : ($paid > 0.0 ? 'partial' : 'pending'),
1140 + 'trip_title' => $trip->title ?? $booking->trip_title ?? __('Trip Booking', 'yatra'),
1141 + 'payment_method' => $this->gatewayLabel(
1142 + $booking->payment_gateway ?? null,
1143 + __('Offline', 'yatra')
1144 + ),
1145 + 'booking_ref' => $bookingRef,
1146 + 'travel_date' => $travelDate,
1147 + 'currency_symbol' => $currencySymbol,
1148 + 'amount' => yatra_format_price((float) $due, $currency, false),
1149 + 'booking_total' => yatra_format_price((float) $total, $currency, false),
1150 + 'amount_paid' => yatra_format_price((float) $paid, $currency, false),
1151 + 'amount_due' => yatra_format_price((float) $due, $currency, false),
1152 + 'tax_breakdown' => [],
1153 + 'tax_amount' => yatra_format_price(0.0, $currency, false),
1154 + 'subtotal' => yatra_format_price((float) $total, $currency, false),
1155 + 'payment_instructions' => is_array($paymentInstructions) ? $paymentInstructions : [],
1156 + ];
1157 +
1158 + $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/invoice.php', $templateData, [
1159 + 'paper' => 'A4',
1160 + 'orientation' => 'portrait',
1161 + 'default_font' => 'DejaVu Sans',
1162 + ]);
1163 +
1164 + if ($isPreview) {
1165 + return new WP_REST_Response([
1166 + 'success' => true,
1167 + 'pdf_data' => base64_encode($pdfBinary),
1168 + 'filename' => $filename,
1169 + ]);
1170 + }
1171 + $pdfService->outputPdfDownload($pdfBinary, $filename);
1172 + exit;
1173 + }
1174 +
1175 + /**
852 1176 * Download travel voucher PDF for a booking
853 1177 */
854 1178 public function download_voucher(WP_REST_Request $request)
855 1179 {
@@ -899,13 +1223,27 @@
899 1223 // Format dates
900 1224 $bookingDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
901 1225 $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
902 1226
903 - // Calculate return date if duration is available
1227 + // Return date. Prefer the booking's STORED end_date — that is the actual
1228 + // booked return (it already accounts for a flexible window or a trip
1229 + // duration that changed after the booking was made). Only when no end is
1230 + // stored do we derive it from the trip duration: duration_days is
1231 + // INCLUSIVE, so the offset is (days - 1) — matching
1232 + // BookingRepository::calculateEndDate. A bare "+ duration_days" was one
1233 + // day too far (see ItineraryPdfBuilder).
904 1234 $returnDate = '';
905 - if (!empty($payment->travel_date) && !empty($trip->duration ?? 0)) {
906 - $returnTimestamp = strtotime($payment->travel_date . ' +' . (int) ($trip->duration ?? 0) . ' days');
907 - $returnDate = date_i18n(get_option('date_format'), $returnTimestamp);
1235 + $storedEnd = isset($payment->booking_end_date) ? (string) $payment->booking_end_date : '';
1236 + $travelStart = (string) ($payment->travel_date ?? '');
1237 + if ($storedEnd !== '' && ($travelStart === '' || $storedEnd >= $travelStart)) {
1238 + $returnDate = date_i18n(get_option('date_format'), strtotime($storedEnd));
1239 + } else {
1240 + $durationDaysForReturn = (int) ($payment->trip_duration_days ?? ($trip->duration_days ?? 0));
1241 + if (!empty($payment->travel_date) && $durationDaysForReturn > 0) {
1242 + $returnOffset = max(0, $durationDaysForReturn - 1);
1243 + $returnTimestamp = strtotime($payment->travel_date . ' +' . $returnOffset . ' days');
1244 + $returnDate = date_i18n(get_option('date_format'), $returnTimestamp);
1245 + }
908 1246 }
909 1247
910 1248 $bookingRef = (string) ($payment->booking_reference ?? $payment->booking_number ?? $payment->reference ?? (string) $paymentId);
911 1249 $filename = 'Travel Voucher #' . $bookingRef . '.pdf';
@@ -921,12 +1259,14 @@
921 1259
922 1260 $templateData = [
923 1261 'company_name' => $companyName,
924 1262 'company_address' => $companyAddress,
1263 + 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(),
925 1264 'company_email' => $companyEmail,
926 1265 'company_phone' => $companyPhone,
927 1266 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
928 1267 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
1268 + 'customer_address_lines' => FormatHelper::customerAddressLines($payment),
929 1269 'booking_ref' => $bookingRef,
930 1270 'booking_date' => $bookingDate,
931 1271 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
932 1272 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
@@ -931,9 +1271,20 @@
931 1271 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
932 1272 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
933 1273 (in_array(strtolower((string) ($payment->status ?? '')), ['cancelled'], true) ? 'cancelled' : 'pending'),
934 1274 'trip_title' => $trip ? ($trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra')) : ($payment->trip_title ?? __('Trip Booking', 'yatra')),
935 - 'trip_duration' => $trip && $trip->duration ? sprintf(__('%d days', 'yatra'), (int) $trip->duration) : '',
1275 + // Trip duration: prefer the duration columns joined onto the payment
1276 + // row (always present, even if the trip was later soft-deleted),
1277 + // falling back to the loaded trip. There is no `duration` column.
1278 + 'trip_duration' => yatra_format_duration(
1279 + (int) ($payment->trip_duration_days ?? ($trip->duration_days ?? 0)),
1280 + isset($payment->trip_duration_nights)
1281 + ? (int) $payment->trip_duration_nights
1282 + : (isset($trip->duration_nights) ? (int) $trip->duration_nights : null),
1283 + // Hour-based day tours: hours come from the loaded trip (the payment
1284 + // join carries only days/nights); a soft-deleted trip falls back to days.
1285 + (int) ($trip->duration_hours ?? 0)
1286 + ),
936 1287 'trip_difficulty' => $trip ? ($trip->difficulty_name ?? '') : '',
937 1288 'departure_location' => $trip ? ($trip->departure_location ?? '') : '',
938 1289 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
939 1290 'travel_date' => $travelDate,
@@ -938,11 +1289,11 @@
938 1289 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
939 1290 'travel_date' => $travelDate,
940 1291 'return_date' => $returnDate,
941 1292 'currency_symbol' => $currencySymbol,
942 - 'total_amount' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
943 - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
944 - 'amount_due' => number_format((float) ($payment->booking_amount_due ?? 0), 2),
1293 + 'total_amount' => yatra_format_price((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), $currency, false),
1294 + 'amount_paid' => yatra_format_price((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), $currency, false),
1295 + 'amount_due' => yatra_format_price((float) ($payment->booking_amount_due ?? 0), $currency, false),
945 1296 'traveler_count' => (int) ($payment->traveler_count ?? 1),
946 1297 ];
947 1298
948 1299 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/voucher.php', $templateData, [
@@ -998,90 +1349,137 @@
998 1349 if ($bookingUserId && $currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
999 1350 return new WP_Error('forbidden', __('You do not have permission to access this itinerary.', 'yatra'), ['status' => 403]);
1000 1351 }
1001 1352
1002 - // Get trip details if available
1003 - $trip = null;
1004 - if (!empty($payment->trip_id)) {
1005 - $trip = $this->tripRepository->find((int) $payment->trip_id);
1353 + // Delegate all the template-data composition + PDF rendering to
1354 + // the shared ItineraryPdfBuilder so the booking-side path
1355 + // (BookingsController::renderItineraryFromBookingData) and this
1356 + // payment-side path produce IDENTICAL PDFs from the same input.
1357 + $builder = new \Yatra\Services\ItineraryPdfBuilder();
1358 + if (!$builder->pdfService()->isAvailable()) {
1359 + return new WP_Error(
1360 + 'pdf_engine_missing',
1361 + __('Itinerary PDF generator is not installed. Please run composer install to install dompdf/dompdf.', 'yatra'),
1362 + ['status' => 500]
1363 + );
1006 1364 }
1007 1365
1008 - // Get company settings
1009 - $companyName = SettingsService::get('company_name', get_bloginfo('name'));
1010 - $companyAddress = SettingsService::get('company_address', '');
1011 - $companyEmail = SettingsService::get('company_email', get_option('admin_email'));
1012 - $companyPhone = SettingsService::get('company_phone', '');
1013 - $currency = SettingsService::getCurrency();
1014 - $currencySymbol = FormatHelper::getCurrencySymbol($currency);
1015 -
1016 - // Format dates
1017 - $bookingDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
1018 - $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
1019 -
1020 - // Calculate return date if duration is available
1021 - $returnDate = '';
1022 - if (!empty($payment->travel_date) && !empty($trip->duration ?? 0)) {
1023 - $returnTimestamp = strtotime($payment->travel_date . ' +' . (int) ($trip->duration ?? 0) . ' days');
1024 - $returnDate = date_i18n(get_option('date_format'), $returnTimestamp);
1025 - }
1026 -
1027 - // Generate booking reference
1028 - $bookingRef = '';
1029 - if (!empty($payment->booking_id)) {
1030 - $bookingRef = 'YTR-' . strtoupper(str_pad((string) $payment->booking_id, 8, '0', STR_PAD_LEFT));
1031 - }
1032 -
1033 - // Generate PDF using PDF service
1034 - $pdfService = new PdfService();
1366 + $bookingRef = !empty($payment->booking_id)
1367 + ? 'YTR-' . strtoupper(str_pad((string) $payment->booking_id, 8, '0', STR_PAD_LEFT))
1368 + : 'PENDING';
1035 1369 $filename = 'Travel-Itinerary-' . $bookingRef . '.pdf';
1036 1370
1037 - // Prepare template data with null-safe access
1038 - $templateData = [
1039 - 'company_name' => $companyName,
1040 - 'company_address' => $companyAddress,
1041 - 'company_email' => $companyEmail,
1042 - 'company_phone' => $companyPhone,
1043 - 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
1044 - 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
1045 - 'booking_ref' => $bookingRef,
1046 - 'booking_date' => $bookingDate,
1047 - 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
1048 - 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
1049 - (in_array(strtolower((string) ($payment->status ?? '')), ['cancelled'], true) ? 'cancelled' : 'pending'),
1050 - 'trip_title' => $trip ? ($trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra')) : ($payment->trip_title ?? __('Trip Booking', 'yatra')),
1051 - 'trip_description' => $trip ? ($trip->description ?? $trip->content ?? '') : '',
1052 - 'trip_duration' => $trip && $trip->duration ? sprintf(__('%d days', 'yatra'), (int) $trip->duration) : '',
1053 - 'trip_difficulty' => $trip ? ($trip->difficulty_name ?? '') : '',
1054 - 'trip_highlights' => $trip ? ($trip->highlights ?? $trip->trip_highlights ?? '') : '',
1055 - 'trip_includes' => $trip ? ($trip->includes ?? $trip->trip_includes ?? '') : '',
1056 - 'trip_excludes' => $trip ? ($trip->excludes ?? $trip->trip_excludes ?? '') : '',
1057 - 'departure_location' => $trip ? ($trip->departure_location ?? '') : '',
1058 - 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
1059 - 'travel_date' => $travelDate,
1060 - 'return_date' => $returnDate,
1061 - 'currency_symbol' => $currencySymbol,
1062 - 'total_amount' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
1063 - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
1064 - 'amount_due' => number_format((float) ($payment->booking_amount_due ?? 0), 2),
1065 - 'traveler_count' => (int) ($payment->traveler_count ?? 1),
1066 - ];
1371 + $pdfBinary = $builder->buildFromPaymentRecord($payment);
1067 1372
1068 - $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/itinerary.php', $templateData, [
1069 - 'paper' => 'A4',
1070 - 'orientation' => 'portrait',
1071 - 'default_font' => 'DejaVu Sans',
1072 - ]);
1073 -
1074 1373 if ($isPreview) {
1075 - // For preview, return PDF as inline display
1076 1374 return new WP_REST_Response([
1077 1375 'success' => true,
1078 1376 'pdf_data' => base64_encode($pdfBinary),
1079 1377 'filename' => $filename,
1080 1378 ]);
1081 - } else {
1082 - // For download, output PDF as download
1083 - $pdfService->outputPdfDownload($pdfBinary, $filename);
1084 - exit;
1085 1379 }
1380 +
1381 + $builder->pdfService()->outputPdfDownload($pdfBinary, $filename);
1382 + exit;
1383 + }
1384 +
1385 + /**
1386 + * Default invoice-token TTL — 1 year. Customers download invoices
1387 + * for tax/expense reports months later, so a short TTL would hurt
1388 + * legitimate use. The TTL is still meaningful as defense-in-depth:
1389 + * a leaked link (forwarded email, posted in a help-desk ticket,
1390 + * cached by a public mail relay) eventually expires.
1391 + *
1392 + * Filterable via `yatra_invoice_token_ttl_seconds` so operators
1393 + * can tighten or loosen on a per-site basis.
1394 + */
1395 + private const INVOICE_TOKEN_DEFAULT_TTL = 365 * 86400;
1396 +
1397 + /**
1398 + * Issue a stateless, signed token that grants access to a single
1399 + * payment's invoice. v2 format embeds an issued-at timestamp so
1400 + * tokens have a defined expiry window — older v1 tokens (no
1401 + * expiry component) are still honored by verifyInvoiceToken() so
1402 + * pre-existing confirmation emails don't break.
1403 + *
1404 + * v2 format: `v2.<iat>.<hmac>` where hmac signs `paymentId|bookingId|iat`.
1405 + * v1 format: bare `<hmac>` over `paymentId|bookingId` (legacy).
1406 + *
1407 + * The token is bound to the payment id + booking id and signed
1408 + * with the WP auth salt, so it cannot be forged without the site
1409 + * secret. It is safe to embed in the confirmation page link so
1410 + * guests (or users who logged out after checkout) can still
1411 + * download their invoice without a session.
1412 + */
1413 + public static function issueInvoiceToken(int $paymentId, int $bookingId): string
1414 + {
1415 + // $paymentId === 0 denotes a booking-scoped (pro-forma) invoice token —
1416 + // used for offline/unpaid bookings that have no payment row yet.
1417 + if ($paymentId < 0 || $bookingId <= 0) {
1418 + return '';
1419 + }
1420 + $iat = time();
1421 + $hmac = hash_hmac(
1422 + 'sha256',
1423 + $paymentId . '|' . $bookingId . '|' . $iat,
1424 + wp_salt('auth') . '|yatra_invoice'
1425 + );
1426 + return 'v2.' . $iat . '.' . $hmac;
1427 + }
1428 +
1429 + /**
1430 + * Verify a token previously issued by self::issueInvoiceToken().
1431 + *
1432 + * Accepts both formats:
1433 + * - v2 (`v2.<iat>.<hmac>`): validates HMAC + checks token age
1434 + * against the configured TTL.
1435 + * - v1 (bare hmac, no expiry): legacy tokens already in the
1436 + * wild via prior confirmation emails. We accept them
1437 + * indefinitely — those URLs were already issued and revoking
1438 + * them now would break existing customer bookmarks.
1439 + */
1440 + public static function verifyInvoiceToken(string $token, int $paymentId, int $bookingId): bool
1441 + {
1442 + // $paymentId === 0 = booking-scoped (pro-forma) token; see issueInvoiceToken().
1443 + if ($token === '' || $paymentId < 0 || $bookingId <= 0) {
1444 + return false;
1445 + }
1446 +
1447 + // v2 path — token starts with the version prefix.
1448 + if (strncmp($token, 'v2.', 3) === 0) {
1449 + $parts = explode('.', $token);
1450 + if (\count($parts) !== 3) return false;
1451 + $iatStr = $parts[1];
1452 + $providedHmac = $parts[2];
1453 + if (!ctype_digit($iatStr)) return false;
1454 + $iat = (int) $iatStr;
1455 +
1456 + $expectedHmac = hash_hmac(
1457 + 'sha256',
1458 + $paymentId . '|' . $bookingId . '|' . $iat,
1459 + wp_salt('auth') . '|yatra_invoice'
1460 + );
1461 + if (!hash_equals($expectedHmac, $providedHmac)) {
1462 + return false;
1463 + }
1464 +
1465 + $ttl = (int) apply_filters(
1466 + 'yatra_invoice_token_ttl_seconds',
1467 + self::INVOICE_TOKEN_DEFAULT_TTL
1468 + );
1469 + if ($ttl > 0 && (time() - $iat) > $ttl) {
1470 + return false;
1471 + }
1472 + return true;
1473 + }
1474 +
1475 + // v1 legacy path — bare HMAC over (paymentId|bookingId).
1476 + // Kept for confirmation emails already sent before the v2
1477 + // upgrade landed. New code paths always issue v2.
1478 + $expectedLegacy = hash_hmac(
1479 + 'sha256',
1480 + $paymentId . '|' . $bookingId,
1481 + wp_salt('auth') . '|yatra_invoice'
1482 + );
1483 + return hash_equals($expectedLegacy, $token);
1086 1484 }
1087 1485 }