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 +578 -234 3.0.2.9 → 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 {
@@ -724,9 +868,11 @@
724 868 return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]);
725 869 }
726 870
727 871 // Authorisation:
728 - // 1. Administrators can always access (no further checks).
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.
729 875 // 2. Logged-in owner of the booking can access.
730 876 // 3. Anyone with a valid signed `invoice_token` (HMAC) can access — used on the
731 877 // booking-confirmation page so guest checkouts and post-session views work.
732 878 // 4. Legacy guest path: `booking_token` (active checkout transient) — kept for BC.
@@ -732,9 +878,9 @@
732 878 // 4. Legacy guest path: `booking_token` (active checkout transient) — kept for BC.
733 879 $currentUserId = (int) get_current_user_id();
734 880 $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0);
735 881 $paymentBookingId = (int) ($payment->booking_id ?? 0);
736 - $isAdmin = current_user_can('manage_options');
882 + $isAdmin = current_user_can('manage_options') || current_user_can('yatra_view_bookings');
737 883 $authorised = false;
738 884
739 885 if ($isAdmin) {
740 886 $authorised = true;
@@ -805,9 +951,11 @@
805 951 $tax_amount += (float) ($tax['amount'] ?? 0);
806 952 $tax_breakdown[] = [
807 953 'name' => $tax['name'] ?? 'Tax',
808 954 'rate' => $tax['rate'] ?? 0,
809 - '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)
810 958 ];
811 959 }
812 960 // Adjust subtotal for tax-exclusive pricing
813 961 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
@@ -818,9 +966,9 @@
818 966 $tax_amount = (float) $payment->tax_amount;
819 967 $tax_breakdown[] = [
820 968 'name' => __('Tax', 'yatra'),
821 969 'rate' => (float) ($payment->tax_rate ?? 0),
822 - 'amount' => $tax_amount
970 + 'amount' => yatra_format_price((float) $tax_amount, $currency, false)
823 971 ];
824 972 // Adjust subtotal for tax-exclusive pricing
825 973 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
826 974 $subtotal = (float) ($payment->subtotal ?? $subtotal);
@@ -831,28 +979,42 @@
831 979
832 980 $templateData = [
833 981 'company_name' => $companyName,
834 982 'company_address' => $companyAddress,
983 + 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(),
835 984 'company_email' => $companyEmail,
836 985 'company_phone' => $companyPhone,
837 986 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
838 987 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
839 - '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)),
840 997 'payment_date' => $paymentDate,
841 998 'payment_status' => ucfirst($payment->status ?? 'paid'),
842 999 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['paid', 'completed', 'success'], true) ? 'paid' : 'pending',
843 1000 'trip_title' => $trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra'),
844 - 'payment_method' => ucfirst($payment->gateway ?? $payment->payment_method ?? 'Online'),
845 - '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 ?? ''),
846 1008 'travel_date' => $travelDate,
847 1009 'currency_symbol' => $currencySymbol,
848 - 'amount' => number_format((float) ($payment->amount ?? 0), 2),
849 - 'booking_total' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
850 - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
851 - '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),
852 1014 'tax_breakdown' => $tax_breakdown,
853 - 'tax_amount' => number_format($tax_amount, 2),
854 - '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),
855 1017 ];
856 1018
857 1019 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/invoice.php', $templateData, [
858 1020 'paper' => 'A4',
@@ -874,8 +1036,144 @@
874 1036 }
875 1037 }
876 1038
877 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 + /**
878 1176 * Download travel voucher PDF for a booking
879 1177 */
880 1178 public function download_voucher(WP_REST_Request $request)
881 1179 {
@@ -925,13 +1223,27 @@
925 1223 // Format dates
926 1224 $bookingDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
927 1225 $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
928 1226
929 - // 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).
930 1234 $returnDate = '';
931 - if (!empty($payment->travel_date) && !empty($trip->duration ?? 0)) {
932 - $returnTimestamp = strtotime($payment->travel_date . ' +' . (int) ($trip->duration ?? 0) . ' days');
933 - $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 + }
934 1246 }
935 1247
936 1248 $bookingRef = (string) ($payment->booking_reference ?? $payment->booking_number ?? $payment->reference ?? (string) $paymentId);
937 1249 $filename = 'Travel Voucher #' . $bookingRef . '.pdf';
@@ -947,12 +1259,14 @@
947 1259
948 1260 $templateData = [
949 1261 'company_name' => $companyName,
950 1262 'company_address' => $companyAddress,
1263 + 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(),
951 1264 'company_email' => $companyEmail,
952 1265 'company_phone' => $companyPhone,
953 1266 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
954 1267 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
1268 + 'customer_address_lines' => FormatHelper::customerAddressLines($payment),
955 1269 'booking_ref' => $bookingRef,
956 1270 'booking_date' => $bookingDate,
957 1271 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
958 1272 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
@@ -957,9 +1271,20 @@
957 1271 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
958 1272 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
959 1273 (in_array(strtolower((string) ($payment->status ?? '')), ['cancelled'], true) ? 'cancelled' : 'pending'),
960 1274 'trip_title' => $trip ? ($trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra')) : ($payment->trip_title ?? __('Trip Booking', 'yatra')),
961 - '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 + ),
962 1287 'trip_difficulty' => $trip ? ($trip->difficulty_name ?? '') : '',
963 1288 'departure_location' => $trip ? ($trip->departure_location ?? '') : '',
964 1289 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
965 1290 'travel_date' => $travelDate,
@@ -964,11 +1289,11 @@
964 1289 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
965 1290 'travel_date' => $travelDate,
966 1291 'return_date' => $returnDate,
967 1292 'currency_symbol' => $currencySymbol,
968 - 'total_amount' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
969 - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
970 - '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),
971 1296 'traveler_count' => (int) ($payment->traveler_count ?? 1),
972 1297 ];
973 1298
974 1299 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/voucher.php', $templateData, [
@@ -1024,118 +1349,137 @@
1024 1349 if ($bookingUserId && $currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
1025 1350 return new WP_Error('forbidden', __('You do not have permission to access this itinerary.', 'yatra'), ['status' => 403]);
1026 1351 }
1027 1352
1028 - // Get trip details if available
1029 - $trip = null;
1030 - if (!empty($payment->trip_id)) {
1031 - $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 + );
1032 1364 }
1033 1365
1034 - // Get company settings
1035 - $companyName = SettingsService::get('company_name', get_bloginfo('name'));
1036 - $companyAddress = SettingsService::get('company_address', '');
1037 - $companyEmail = SettingsService::get('company_email', get_option('admin_email'));
1038 - $companyPhone = SettingsService::get('company_phone', '');
1039 - $currency = SettingsService::getCurrency();
1040 - $currencySymbol = FormatHelper::getCurrencySymbol($currency);
1041 -
1042 - // Format dates
1043 - $bookingDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
1044 - $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
1045 -
1046 - // Calculate return date if duration is available
1047 - $returnDate = '';
1048 - if (!empty($payment->travel_date) && !empty($trip->duration ?? 0)) {
1049 - $returnTimestamp = strtotime($payment->travel_date . ' +' . (int) ($trip->duration ?? 0) . ' days');
1050 - $returnDate = date_i18n(get_option('date_format'), $returnTimestamp);
1051 - }
1052 -
1053 - // Generate booking reference
1054 - $bookingRef = '';
1055 - if (!empty($payment->booking_id)) {
1056 - $bookingRef = 'YTR-' . strtoupper(str_pad((string) $payment->booking_id, 8, '0', STR_PAD_LEFT));
1057 - }
1058 -
1059 - // Generate PDF using PDF service
1060 - $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';
1061 1369 $filename = 'Travel-Itinerary-' . $bookingRef . '.pdf';
1062 1370
1063 - // Prepare template data with null-safe access
1064 - $templateData = [
1065 - 'company_name' => $companyName,
1066 - 'company_address' => $companyAddress,
1067 - 'company_email' => $companyEmail,
1068 - 'company_phone' => $companyPhone,
1069 - 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
1070 - 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
1071 - 'booking_ref' => $bookingRef,
1072 - 'booking_date' => $bookingDate,
1073 - 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
1074 - 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
1075 - (in_array(strtolower((string) ($payment->status ?? '')), ['cancelled'], true) ? 'cancelled' : 'pending'),
1076 - 'trip_title' => $trip ? ($trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra')) : ($payment->trip_title ?? __('Trip Booking', 'yatra')),
1077 - 'trip_description' => $trip ? ($trip->description ?? $trip->content ?? '') : '',
1078 - 'trip_duration' => $trip && $trip->duration ? sprintf(__('%d days', 'yatra'), (int) $trip->duration) : '',
1079 - 'trip_difficulty' => $trip ? ($trip->difficulty_name ?? '') : '',
1080 - 'trip_highlights' => $trip ? ($trip->highlights ?? $trip->trip_highlights ?? '') : '',
1081 - 'trip_includes' => $trip ? ($trip->includes ?? $trip->trip_includes ?? '') : '',
1082 - 'trip_excludes' => $trip ? ($trip->excludes ?? $trip->trip_excludes ?? '') : '',
1083 - 'departure_location' => $trip ? ($trip->departure_location ?? '') : '',
1084 - 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
1085 - 'travel_date' => $travelDate,
1086 - 'return_date' => $returnDate,
1087 - 'currency_symbol' => $currencySymbol,
1088 - 'total_amount' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
1089 - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
1090 - 'amount_due' => number_format((float) ($payment->booking_amount_due ?? 0), 2),
1091 - 'traveler_count' => (int) ($payment->traveler_count ?? 1),
1092 - ];
1371 + $pdfBinary = $builder->buildFromPaymentRecord($payment);
1093 1372
1094 - $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/itinerary.php', $templateData, [
1095 - 'paper' => 'A4',
1096 - 'orientation' => 'portrait',
1097 - 'default_font' => 'DejaVu Sans',
1098 - ]);
1099 -
1100 1373 if ($isPreview) {
1101 - // For preview, return PDF as inline display
1102 1374 return new WP_REST_Response([
1103 1375 'success' => true,
1104 1376 'pdf_data' => base64_encode($pdfBinary),
1105 1377 'filename' => $filename,
1106 1378 ]);
1107 - } else {
1108 - // For download, output PDF as download
1109 - $pdfService->outputPdfDownload($pdfBinary, $filename);
1110 - exit;
1111 1379 }
1380 +
1381 + $builder->pdfService()->outputPdfDownload($pdfBinary, $filename);
1382 + exit;
1112 1383 }
1113 1384
1114 1385 /**
1115 - * Issue a stateless, signed token that grants access to a single payment's invoice.
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.
1116 1391 *
1117 - * The token is bound to the payment id + booking id and signed with the WP auth salt,
1118 - * so it cannot be forged without the site secret. It is safe to embed in the
1119 - * confirmation page link so guests (or users who logged out after checkout) can still
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
1120 1411 * download their invoice without a session.
1121 1412 */
1122 1413 public static function issueInvoiceToken(int $paymentId, int $bookingId): string
1123 1414 {
1124 - if ($paymentId <= 0 || $bookingId <= 0) {
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) {
1125 1418 return '';
1126 1419 }
1127 - return hash_hmac('sha256', $paymentId . '|' . $bookingId, wp_salt('auth') . '|yatra_invoice');
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;
1128 1427 }
1129 1428
1130 1429 /**
1131 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.
1132 1439 */
1133 1440 public static function verifyInvoiceToken(string $token, int $paymentId, int $bookingId): bool
1134 1441 {
1135 - if ($token === '' || $paymentId <= 0 || $bookingId <= 0) {
1442 + // $paymentId === 0 = booking-scoped (pro-forma) token; see issueInvoiceToken().
1443 + if ($token === '' || $paymentId < 0 || $bookingId <= 0) {
1136 1444 return false;
1137 1445 }
1138 - $expected = self::issueInvoiceToken($paymentId, $bookingId);
1139 - return $expected !== '' && hash_equals($expected, $token);
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);
1140 1484 }
1141 1485 }