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 +568 -125 3.0.3 → 3.0.16 View file →
@@ -144,16 +144,31 @@
144 144 'callback' => [$this, 'download_voucher'],
145 145 'permission_callback' => '__return_true', // Auth checked inside callback
146 146 ],
147 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 + ]);
148 159 }
149 160
150 161 /**
151 - * 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.
152 167 */
153 168 public function check_admin_permission(): bool
154 169 {
155 - return current_user_can('manage_options');
170 + return current_user_can('yatra_manage_payment_gateways');
156 171 }
157 172
158 173 public function check_customer_permission(): bool
159 174 {
@@ -199,8 +214,22 @@
199 214
200 215 $customerEmail = $booking->contact_email ?? ($booking->customer_email ?? '');
201 216 $customerName = trim(($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''));
202 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 +
203 232 $paymentData = [
204 233 'amount' => $remainingAmount,
205 234 'currency' => $booking->currency ?? get_option('yatra_currency', 'USD'),
206 235 'booking_id' => $bookingId,
@@ -205,11 +234,15 @@
205 234 'currency' => $booking->currency ?? get_option('yatra_currency', 'USD'),
206 235 'booking_id' => $bookingId,
207 236 'customer_email' => $customerEmail,
208 237 'customer_name' => $customerName ?: $customerEmail,
209 - 'return_url' => $this->getConfirmationUrl($booking->reference ?? (string) $bookingId),
210 - 'description' => sprintf(__('Remaining balance for Booking #%s', 'yatra'), $booking->reference ?? $bookingId),
211 - '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,
212 245 ];
213 246
214 247 $result = $this->registry->processPayment($method, $paymentData);
215 248
@@ -306,15 +339,45 @@
306 339 ]);
307 340 }
308 341
309 342 /**
310 - * 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.
311 351 */
312 352 public function get_available_gateways(WP_REST_Request $request): WP_REST_Response
313 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 +
314 376 return new WP_REST_Response([
315 - 'gateways' => $this->registry->getForCheckout(),
377 + 'gateways' => $gateways,
316 378 'currency' => get_option('yatra_currency', 'USD'),
379 + 'context' => $isRemainingFlow ? 'remaining' : 'initial',
317 380 ], 200);
318 381 }
319 382
320 383 /**
@@ -364,18 +427,54 @@
364 427 'customer_name' => sanitize_text_field($request->get_param('customer_name')),
365 428 'return_url' => esc_url_raw($request->get_param('return_url')),
366 429 ];
367 430
368 - // 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.
369 435 if ($paymentData['booking_id'] > 0) {
370 436 $booking = $this->bookingRepository->find($paymentData['booking_id']);
371 - if ($booking) {
372 - $paymentData['reference'] = $booking->reference ?? '';
373 - $paymentData['trip_title'] = $booking->trip_title ?? '';
374 - if (empty($paymentData['trip_id'])) {
375 - $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]);
376 448 }
377 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 + }
378 477 }
379 478
380 479 if (empty($paymentData['return_url'])) {
381 480 $reference = $paymentData['reference'] ?? (string) $paymentData['booking_id'];
@@ -382,9 +481,12 @@
382 481 $paymentData['return_url'] = add_query_arg('payment', 'success', $this->getConfirmationUrl($reference));
383 482 }
384 483
385 484 $cancelParam = esc_url_raw($request->get_param('cancel_url'));
386 - $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));
387 489
388 490 if ($paymentData['amount'] <= 0) {
389 491 return new WP_Error('invalid_amount', __('Invalid payment amount', 'yatra'), ['status' => 400]);
390 492 }
@@ -404,8 +506,30 @@
404 506 return yatra_get_booking_confirmation_url($reference);
405 507 }
406 508
407 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 + /**
408 532 * Confirm payment
409 533 */
410 534 public function confirm_payment(WP_REST_Request $request)
411 535 {
@@ -418,8 +542,63 @@
418 542 if (!$gateway) {
419 543 return new WP_Error('invalid_gateway', __('Gateway not found', 'yatra'), ['status' => 404]);
420 544 }
421 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 +
422 601 $result = $gateway->verifyPayment($transactionId);
423 602
424 603 if ($result['success']) {
425 604 // Get customer and payment method from result
@@ -433,12 +612,12 @@
433 612 $bookingId
434 613 );
435 614
436 615 $this->handle_successful_payment(
437 - $bookingId,
438 - $gatewayId,
439 - $transactionId,
440 - $result['amount'] ?? null,
616 + $bookingId,
617 + $gatewayId,
618 + $transactionId,
619 + $result['amount'] ?? null,
441 620 $result['currency'] ?? null,
442 621 ($saveCard || $passForSchedule) ? $customerId : null,
443 622 ($saveCard || $passForSchedule) ? $paymentMethodId : null
444 623 );
@@ -484,10 +663,19 @@
484 663 wp_redirect(home_url('/booking-failed/'));
485 664 exit;
486 665 }
487 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 +
488 676 // Get transaction ID from request (varies by gateway)
489 - $transactionId = $request->get_param('refId')
677 + $transactionId = $request->get_param('refId')
490 678 ?? $request->get_param('pidx')
491 679 ?? $request->get_param('transaction_id')
492 680 ?? '';
493 681
@@ -506,13 +694,23 @@
506 694 }
507 695
508 696 /**
509 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.
510 703 */
511 704 public function get_payment_status(WP_REST_Request $request)
512 705 {
513 706 $bookingId = (int) $request->get_param('booking_id');
707 + $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? ''));
514 708
709 + if ($bookingId <= 0) {
710 + return new WP_Error('invalid_booking', __('Invalid booking ID.', 'yatra'), ['status' => 400]);
711 + }
712 +
515 713 $payment = $this->paymentRepository->findLatestByBookingId($bookingId);
516 714
517 715 if (!$payment) {
518 716 return new WP_Error('payment_not_found', __('Payment not found', 'yatra'), ['status' => 404]);
@@ -517,8 +715,33 @@
517 715 if (!$payment) {
518 716 return new WP_Error('payment_not_found', __('Payment not found', 'yatra'), ['status' => 404]);
519 717 }
520 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 +
521 744 return new WP_REST_Response([
522 745 'status' => $payment->status,
523 746 'amount' => (float) $payment->amount,
524 747 'currency' => $payment->currency,
@@ -554,8 +777,18 @@
554 777
555 778 $paid_amount = $amount ?? (float) $booking->amount_due;
556 779 $payment_currency = $currency ?? $booking->currency;
557 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 +
558 791 $payment_data = [
559 792 'booking_id' => $bookingId,
560 793 'gateway' => $gateway,
561 794 'transaction_id' => $transactionId,
@@ -579,17 +812,27 @@
579 812 }
580 813
581 814 $previousBookingStatus = (string) ($booking->status ?? 'pending');
582 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 +
583 821 // Update booking
584 - $this->bookingRepository->update($bookingId, [
822 + $booking_update = [
585 823 'amount_paid' => $new_amount_paid,
586 824 'amount_due' => $new_amount_due,
587 825 'payment_status' => $payment_status,
588 - 'status' => 'confirmed',
589 - ]);
826 + ];
827 + if ($should_confirm) {
828 + $booking_update['status'] = 'confirmed';
829 + }
830 + $this->bookingRepository->update($bookingId, $booking_update);
590 831
591 - \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus);
832 + if ($should_confirm) {
833 + \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus, true);
834 + }
592 835
593 836 // Clear remaining payment session if this was a remaining payment
594 837 if (function_exists('yatra_has_remaining_session') && yatra_has_remaining_session()) {
595 838 yatra_clear_remaining_session();
@@ -625,9 +868,11 @@
625 868 return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]);
626 869 }
627 870
628 871 // Authorisation:
629 - // 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.
630 875 // 2. Logged-in owner of the booking can access.
631 876 // 3. Anyone with a valid signed `invoice_token` (HMAC) can access — used on the
632 877 // booking-confirmation page so guest checkouts and post-session views work.
633 878 // 4. Legacy guest path: `booking_token` (active checkout transient) — kept for BC.
@@ -633,9 +878,9 @@
633 878 // 4. Legacy guest path: `booking_token` (active checkout transient) — kept for BC.
634 879 $currentUserId = (int) get_current_user_id();
635 880 $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0);
636 881 $paymentBookingId = (int) ($payment->booking_id ?? 0);
637 - $isAdmin = current_user_can('manage_options');
882 + $isAdmin = current_user_can('manage_options') || current_user_can('yatra_view_bookings');
638 883 $authorised = false;
639 884
640 885 if ($isAdmin) {
641 886 $authorised = true;
@@ -706,9 +951,11 @@
706 951 $tax_amount += (float) ($tax['amount'] ?? 0);
707 952 $tax_breakdown[] = [
708 953 'name' => $tax['name'] ?? 'Tax',
709 954 'rate' => $tax['rate'] ?? 0,
710 - '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)
711 958 ];
712 959 }
713 960 // Adjust subtotal for tax-exclusive pricing
714 961 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
@@ -719,9 +966,9 @@
719 966 $tax_amount = (float) $payment->tax_amount;
720 967 $tax_breakdown[] = [
721 968 'name' => __('Tax', 'yatra'),
722 969 'rate' => (float) ($payment->tax_rate ?? 0),
723 - 'amount' => $tax_amount
970 + 'amount' => yatra_format_price((float) $tax_amount, $currency, false)
724 971 ];
725 972 // Adjust subtotal for tax-exclusive pricing
726 973 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
727 974 $subtotal = (float) ($payment->subtotal ?? $subtotal);
@@ -732,28 +979,42 @@
732 979
733 980 $templateData = [
734 981 'company_name' => $companyName,
735 982 'company_address' => $companyAddress,
983 + 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(),
736 984 'company_email' => $companyEmail,
737 985 'company_phone' => $companyPhone,
738 986 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
739 987 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
740 - '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)),
741 997 'payment_date' => $paymentDate,
742 998 'payment_status' => ucfirst($payment->status ?? 'paid'),
743 999 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['paid', 'completed', 'success'], true) ? 'paid' : 'pending',
744 1000 'trip_title' => $trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra'),
745 - 'payment_method' => ucfirst($payment->gateway ?? $payment->payment_method ?? 'Online'),
746 - '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 ?? ''),
747 1008 'travel_date' => $travelDate,
748 1009 'currency_symbol' => $currencySymbol,
749 - 'amount' => number_format((float) ($payment->amount ?? 0), 2),
750 - 'booking_total' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
751 - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
752 - '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),
753 1014 'tax_breakdown' => $tax_breakdown,
754 - 'tax_amount' => number_format($tax_amount, 2),
755 - '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),
756 1017 ];
757 1018
758 1019 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/invoice.php', $templateData, [
759 1020 'paper' => 'A4',
@@ -775,8 +1036,144 @@
775 1036 }
776 1037 }
777 1038
778 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 + /**
779 1176 * Download travel voucher PDF for a booking
780 1177 */
781 1178 public function download_voucher(WP_REST_Request $request)
782 1179 {
@@ -826,13 +1223,27 @@
826 1223 // Format dates
827 1224 $bookingDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
828 1225 $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
829 1226
830 - // 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).
831 1234 $returnDate = '';
832 - if (!empty($payment->travel_date) && !empty($trip->duration ?? 0)) {
833 - $returnTimestamp = strtotime($payment->travel_date . ' +' . (int) ($trip->duration ?? 0) . ' days');
834 - $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 + }
835 1246 }
836 1247
837 1248 $bookingRef = (string) ($payment->booking_reference ?? $payment->booking_number ?? $payment->reference ?? (string) $paymentId);
838 1249 $filename = 'Travel Voucher #' . $bookingRef . '.pdf';
@@ -848,12 +1259,14 @@
848 1259
849 1260 $templateData = [
850 1261 'company_name' => $companyName,
851 1262 'company_address' => $companyAddress,
1263 + 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(),
852 1264 'company_email' => $companyEmail,
853 1265 'company_phone' => $companyPhone,
854 1266 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
855 1267 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
1268 + 'customer_address_lines' => FormatHelper::customerAddressLines($payment),
856 1269 'booking_ref' => $bookingRef,
857 1270 'booking_date' => $bookingDate,
858 1271 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
859 1272 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
@@ -858,9 +1271,20 @@
858 1271 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
859 1272 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
860 1273 (in_array(strtolower((string) ($payment->status ?? '')), ['cancelled'], true) ? 'cancelled' : 'pending'),
861 1274 'trip_title' => $trip ? ($trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra')) : ($payment->trip_title ?? __('Trip Booking', 'yatra')),
862 - '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 + ),
863 1287 'trip_difficulty' => $trip ? ($trip->difficulty_name ?? '') : '',
864 1288 'departure_location' => $trip ? ($trip->departure_location ?? '') : '',
865 1289 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
866 1290 'travel_date' => $travelDate,
@@ -865,11 +1289,11 @@
865 1289 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
866 1290 'travel_date' => $travelDate,
867 1291 'return_date' => $returnDate,
868 1292 'currency_symbol' => $currencySymbol,
869 - 'total_amount' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
870 - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
871 - '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),
872 1296 'traveler_count' => (int) ($payment->traveler_count ?? 1),
873 1297 ];
874 1298
875 1299 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/voucher.php', $templateData, [
@@ -925,118 +1349,137 @@
925 1349 if ($bookingUserId && $currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
926 1350 return new WP_Error('forbidden', __('You do not have permission to access this itinerary.', 'yatra'), ['status' => 403]);
927 1351 }
928 1352
929 - // Get trip details if available
930 - $trip = null;
931 - if (!empty($payment->trip_id)) {
932 - $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 + );
933 1364 }
934 1365
935 - // Get company settings
936 - $companyName = SettingsService::get('company_name', get_bloginfo('name'));
937 - $companyAddress = SettingsService::get('company_address', '');
938 - $companyEmail = SettingsService::get('company_email', get_option('admin_email'));
939 - $companyPhone = SettingsService::get('company_phone', '');
940 - $currency = SettingsService::getCurrency();
941 - $currencySymbol = FormatHelper::getCurrencySymbol($currency);
942 -
943 - // Format dates
944 - $bookingDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
945 - $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
946 -
947 - // Calculate return date if duration is available
948 - $returnDate = '';
949 - if (!empty($payment->travel_date) && !empty($trip->duration ?? 0)) {
950 - $returnTimestamp = strtotime($payment->travel_date . ' +' . (int) ($trip->duration ?? 0) . ' days');
951 - $returnDate = date_i18n(get_option('date_format'), $returnTimestamp);
952 - }
953 -
954 - // Generate booking reference
955 - $bookingRef = '';
956 - if (!empty($payment->booking_id)) {
957 - $bookingRef = 'YTR-' . strtoupper(str_pad((string) $payment->booking_id, 8, '0', STR_PAD_LEFT));
958 - }
959 -
960 - // Generate PDF using PDF service
961 - $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';
962 1369 $filename = 'Travel-Itinerary-' . $bookingRef . '.pdf';
963 1370
964 - // Prepare template data with null-safe access
965 - $templateData = [
966 - 'company_name' => $companyName,
967 - 'company_address' => $companyAddress,
968 - 'company_email' => $companyEmail,
969 - 'company_phone' => $companyPhone,
970 - 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
971 - 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
972 - 'booking_ref' => $bookingRef,
973 - 'booking_date' => $bookingDate,
974 - 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
975 - 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
976 - (in_array(strtolower((string) ($payment->status ?? '')), ['cancelled'], true) ? 'cancelled' : 'pending'),
977 - 'trip_title' => $trip ? ($trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra')) : ($payment->trip_title ?? __('Trip Booking', 'yatra')),
978 - 'trip_description' => $trip ? ($trip->description ?? $trip->content ?? '') : '',
979 - 'trip_duration' => $trip && $trip->duration ? sprintf(__('%d days', 'yatra'), (int) $trip->duration) : '',
980 - 'trip_difficulty' => $trip ? ($trip->difficulty_name ?? '') : '',
981 - 'trip_highlights' => $trip ? ($trip->highlights ?? $trip->trip_highlights ?? '') : '',
982 - 'trip_includes' => $trip ? ($trip->includes ?? $trip->trip_includes ?? '') : '',
983 - 'trip_excludes' => $trip ? ($trip->excludes ?? $trip->trip_excludes ?? '') : '',
984 - 'departure_location' => $trip ? ($trip->departure_location ?? '') : '',
985 - 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
986 - 'travel_date' => $travelDate,
987 - 'return_date' => $returnDate,
988 - 'currency_symbol' => $currencySymbol,
989 - 'total_amount' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
990 - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
991 - 'amount_due' => number_format((float) ($payment->booking_amount_due ?? 0), 2),
992 - 'traveler_count' => (int) ($payment->traveler_count ?? 1),
993 - ];
1371 + $pdfBinary = $builder->buildFromPaymentRecord($payment);
994 1372
995 - $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/itinerary.php', $templateData, [
996 - 'paper' => 'A4',
997 - 'orientation' => 'portrait',
998 - 'default_font' => 'DejaVu Sans',
999 - ]);
1000 -
1001 1373 if ($isPreview) {
1002 - // For preview, return PDF as inline display
1003 1374 return new WP_REST_Response([
1004 1375 'success' => true,
1005 1376 'pdf_data' => base64_encode($pdfBinary),
1006 1377 'filename' => $filename,
1007 1378 ]);
1008 - } else {
1009 - // For download, output PDF as download
1010 - $pdfService->outputPdfDownload($pdfBinary, $filename);
1011 - exit;
1012 1379 }
1380 +
1381 + $builder->pdfService()->outputPdfDownload($pdfBinary, $filename);
1382 + exit;
1013 1383 }
1014 1384
1015 1385 /**
1016 - * 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.
1017 1391 *
1018 - * The token is bound to the payment id + booking id and signed with the WP auth salt,
1019 - * so it cannot be forged without the site secret. It is safe to embed in the
1020 - * 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
1021 1411 * download their invoice without a session.
1022 1412 */
1023 1413 public static function issueInvoiceToken(int $paymentId, int $bookingId): string
1024 1414 {
1025 - 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) {
1026 1418 return '';
1027 1419 }
1028 - 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;
1029 1427 }
1030 1428
1031 1429 /**
1032 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.
1033 1439 */
1034 1440 public static function verifyInvoiceToken(string $token, int $paymentId, int $bookingId): bool
1035 1441 {
1036 - if ($token === '' || $paymentId <= 0 || $bookingId <= 0) {
1442 + // $paymentId === 0 = booking-scoped (pro-forma) token; see issueInvoiceToken().
1443 + if ($token === '' || $paymentId < 0 || $bookingId <= 0) {
1037 1444 return false;
1038 1445 }
1039 - $expected = self::issueInvoiceToken($paymentId, $bookingId);
1040 - 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);
1041 1484 }
1042 1485 }