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 +366 -111 3.0.4 → 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 {
@@ -220,9 +235,13 @@
220 235 'booking_id' => $bookingId,
221 236 'customer_email' => $customerEmail,
222 237 'customer_name' => $customerName ?: $customerEmail,
223 238 'return_url' => $confirmationUrl,
224 - 'description' => sprintf(__('Remaining balance for Booking #%s', 'yatra'), $booking->reference ?? $bookingId),
239 + 'description' => sprintf(
240 + /* translators: %s: booking reference. */
241 + __('Remaining balance for Booking #%s', 'yatra'),
242 + $booking->reference ?? $bookingId
243 + ),
225 244 'cancel_url' => $cancelUrl,
226 245 ];
227 246
228 247 $result = $this->registry->processPayment($method, $paymentData);
@@ -462,9 +481,12 @@
462 481 $paymentData['return_url'] = add_query_arg('payment', 'success', $this->getConfirmationUrl($reference));
463 482 }
464 483
465 484 $cancelParam = esc_url_raw($request->get_param('cancel_url'));
466 - $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));
467 489
468 490 if ($paymentData['amount'] <= 0) {
469 491 return new WP_Error('invalid_amount', __('Invalid payment amount', 'yatra'), ['status' => 400]);
470 492 }
@@ -520,8 +542,22 @@
520 542 if (!$gateway) {
521 543 return new WP_Error('invalid_gateway', __('Gateway not found', 'yatra'), ['status' => 404]);
522 544 }
523 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 +
524 560 if ($bookingId <= 0 || $transactionId === '') {
525 561 return new WP_Error('invalid_request', __('booking_id and transaction_id are required.', 'yatra'), ['status' => 400]);
526 562 }
527 563
@@ -627,10 +663,19 @@
627 663 wp_redirect(home_url('/booking-failed/'));
628 664 exit;
629 665 }
630 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 +
631 676 // Get transaction ID from request (varies by gateway)
632 - $transactionId = $request->get_param('refId')
677 + $transactionId = $request->get_param('refId')
633 678 ?? $request->get_param('pidx')
634 679 ?? $request->get_param('transaction_id')
635 680 ?? '';
636 681
@@ -767,17 +812,27 @@
767 812 }
768 813
769 814 $previousBookingStatus = (string) ($booking->status ?? 'pending');
770 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 +
771 821 // Update booking
772 - $this->bookingRepository->update($bookingId, [
822 + $booking_update = [
773 823 'amount_paid' => $new_amount_paid,
774 824 'amount_due' => $new_amount_due,
775 825 'payment_status' => $payment_status,
776 - 'status' => 'confirmed',
777 - ]);
826 + ];
827 + if ($should_confirm) {
828 + $booking_update['status'] = 'confirmed';
829 + }
830 + $this->bookingRepository->update($bookingId, $booking_update);
778 831
779 - \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus);
832 + if ($should_confirm) {
833 + \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus, true);
834 + }
780 835
781 836 // Clear remaining payment session if this was a remaining payment
782 837 if (function_exists('yatra_has_remaining_session') && yatra_has_remaining_session()) {
783 838 yatra_clear_remaining_session();
@@ -813,9 +868,11 @@
813 868 return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]);
814 869 }
815 870
816 871 // Authorisation:
817 - // 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.
818 875 // 2. Logged-in owner of the booking can access.
819 876 // 3. Anyone with a valid signed `invoice_token` (HMAC) can access — used on the
820 877 // booking-confirmation page so guest checkouts and post-session views work.
821 878 // 4. Legacy guest path: `booking_token` (active checkout transient) — kept for BC.
@@ -821,9 +878,9 @@
821 878 // 4. Legacy guest path: `booking_token` (active checkout transient) — kept for BC.
822 879 $currentUserId = (int) get_current_user_id();
823 880 $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0);
824 881 $paymentBookingId = (int) ($payment->booking_id ?? 0);
825 - $isAdmin = current_user_can('manage_options');
882 + $isAdmin = current_user_can('manage_options') || current_user_can('yatra_view_bookings');
826 883 $authorised = false;
827 884
828 885 if ($isAdmin) {
829 886 $authorised = true;
@@ -894,9 +951,11 @@
894 951 $tax_amount += (float) ($tax['amount'] ?? 0);
895 952 $tax_breakdown[] = [
896 953 'name' => $tax['name'] ?? 'Tax',
897 954 'rate' => $tax['rate'] ?? 0,
898 - '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)
899 958 ];
900 959 }
901 960 // Adjust subtotal for tax-exclusive pricing
902 961 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
@@ -907,9 +966,9 @@
907 966 $tax_amount = (float) $payment->tax_amount;
908 967 $tax_breakdown[] = [
909 968 'name' => __('Tax', 'yatra'),
910 969 'rate' => (float) ($payment->tax_rate ?? 0),
911 - 'amount' => $tax_amount
970 + 'amount' => yatra_format_price((float) $tax_amount, $currency, false)
912 971 ];
913 972 // Adjust subtotal for tax-exclusive pricing
914 973 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
915 974 $subtotal = (float) ($payment->subtotal ?? $subtotal);
@@ -920,28 +979,42 @@
920 979
921 980 $templateData = [
922 981 'company_name' => $companyName,
923 982 'company_address' => $companyAddress,
983 + 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(),
924 984 'company_email' => $companyEmail,
925 985 'company_phone' => $companyPhone,
926 986 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
927 987 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
928 - '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)),
929 997 'payment_date' => $paymentDate,
930 998 'payment_status' => ucfirst($payment->status ?? 'paid'),
931 999 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['paid', 'completed', 'success'], true) ? 'paid' : 'pending',
932 1000 'trip_title' => $trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra'),
933 - 'payment_method' => ucfirst($payment->gateway ?? $payment->payment_method ?? 'Online'),
934 - '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 ?? ''),
935 1008 'travel_date' => $travelDate,
936 1009 'currency_symbol' => $currencySymbol,
937 - 'amount' => number_format((float) ($payment->amount ?? 0), 2),
938 - 'booking_total' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
939 - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
940 - '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),
941 1014 'tax_breakdown' => $tax_breakdown,
942 - 'tax_amount' => number_format($tax_amount, 2),
943 - '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),
944 1017 ];
945 1018
946 1019 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/invoice.php', $templateData, [
947 1020 'paper' => 'A4',
@@ -963,8 +1036,144 @@
963 1036 }
964 1037 }
965 1038
966 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 + /**
967 1176 * Download travel voucher PDF for a booking
968 1177 */
969 1178 public function download_voucher(WP_REST_Request $request)
970 1179 {
@@ -1014,13 +1223,27 @@
1014 1223 // Format dates
1015 1224 $bookingDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
1016 1225 $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
1017 1226
1018 - // 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).
1019 1234 $returnDate = '';
1020 - if (!empty($payment->travel_date) && !empty($trip->duration ?? 0)) {
1021 - $returnTimestamp = strtotime($payment->travel_date . ' +' . (int) ($trip->duration ?? 0) . ' days');
1022 - $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 + }
1023 1246 }
1024 1247
1025 1248 $bookingRef = (string) ($payment->booking_reference ?? $payment->booking_number ?? $payment->reference ?? (string) $paymentId);
1026 1249 $filename = 'Travel Voucher #' . $bookingRef . '.pdf';
@@ -1036,12 +1259,14 @@
1036 1259
1037 1260 $templateData = [
1038 1261 'company_name' => $companyName,
1039 1262 'company_address' => $companyAddress,
1263 + 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(),
1040 1264 'company_email' => $companyEmail,
1041 1265 'company_phone' => $companyPhone,
1042 1266 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
1043 1267 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
1268 + 'customer_address_lines' => FormatHelper::customerAddressLines($payment),
1044 1269 'booking_ref' => $bookingRef,
1045 1270 'booking_date' => $bookingDate,
1046 1271 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
1047 1272 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
@@ -1046,9 +1271,20 @@
1046 1271 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
1047 1272 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
1048 1273 (in_array(strtolower((string) ($payment->status ?? '')), ['cancelled'], true) ? 'cancelled' : 'pending'),
1049 1274 'trip_title' => $trip ? ($trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra')) : ($payment->trip_title ?? __('Trip Booking', 'yatra')),
1050 - '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 + ),
1051 1287 'trip_difficulty' => $trip ? ($trip->difficulty_name ?? '') : '',
1052 1288 'departure_location' => $trip ? ($trip->departure_location ?? '') : '',
1053 1289 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
1054 1290 'travel_date' => $travelDate,
@@ -1053,11 +1289,11 @@
1053 1289 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
1054 1290 'travel_date' => $travelDate,
1055 1291 'return_date' => $returnDate,
1056 1292 'currency_symbol' => $currencySymbol,
1057 - 'total_amount' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
1058 - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
1059 - '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),
1060 1296 'traveler_count' => (int) ($payment->traveler_count ?? 1),
1061 1297 ];
1062 1298
1063 1299 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/voucher.php', $templateData, [
@@ -1113,118 +1349,137 @@
1113 1349 if ($bookingUserId && $currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
1114 1350 return new WP_Error('forbidden', __('You do not have permission to access this itinerary.', 'yatra'), ['status' => 403]);
1115 1351 }
1116 1352
1117 - // Get trip details if available
1118 - $trip = null;
1119 - if (!empty($payment->trip_id)) {
1120 - $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 + );
1121 1364 }
1122 1365
1123 - // Get company settings
1124 - $companyName = SettingsService::get('company_name', get_bloginfo('name'));
1125 - $companyAddress = SettingsService::get('company_address', '');
1126 - $companyEmail = SettingsService::get('company_email', get_option('admin_email'));
1127 - $companyPhone = SettingsService::get('company_phone', '');
1128 - $currency = SettingsService::getCurrency();
1129 - $currencySymbol = FormatHelper::getCurrencySymbol($currency);
1130 -
1131 - // Format dates
1132 - $bookingDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
1133 - $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
1134 -
1135 - // Calculate return date if duration is available
1136 - $returnDate = '';
1137 - if (!empty($payment->travel_date) && !empty($trip->duration ?? 0)) {
1138 - $returnTimestamp = strtotime($payment->travel_date . ' +' . (int) ($trip->duration ?? 0) . ' days');
1139 - $returnDate = date_i18n(get_option('date_format'), $returnTimestamp);
1140 - }
1141 -
1142 - // Generate booking reference
1143 - $bookingRef = '';
1144 - if (!empty($payment->booking_id)) {
1145 - $bookingRef = 'YTR-' . strtoupper(str_pad((string) $payment->booking_id, 8, '0', STR_PAD_LEFT));
1146 - }
1147 -
1148 - // Generate PDF using PDF service
1149 - $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';
1150 1369 $filename = 'Travel-Itinerary-' . $bookingRef . '.pdf';
1151 1370
1152 - // Prepare template data with null-safe access
1153 - $templateData = [
1154 - 'company_name' => $companyName,
1155 - 'company_address' => $companyAddress,
1156 - 'company_email' => $companyEmail,
1157 - 'company_phone' => $companyPhone,
1158 - 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
1159 - 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
1160 - 'booking_ref' => $bookingRef,
1161 - 'booking_date' => $bookingDate,
1162 - 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
1163 - 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
1164 - (in_array(strtolower((string) ($payment->status ?? '')), ['cancelled'], true) ? 'cancelled' : 'pending'),
1165 - 'trip_title' => $trip ? ($trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra')) : ($payment->trip_title ?? __('Trip Booking', 'yatra')),
1166 - 'trip_description' => $trip ? ($trip->description ?? $trip->content ?? '') : '',
1167 - 'trip_duration' => $trip && $trip->duration ? sprintf(__('%d days', 'yatra'), (int) $trip->duration) : '',
1168 - 'trip_difficulty' => $trip ? ($trip->difficulty_name ?? '') : '',
1169 - 'trip_highlights' => $trip ? ($trip->highlights ?? $trip->trip_highlights ?? '') : '',
1170 - 'trip_includes' => $trip ? ($trip->includes ?? $trip->trip_includes ?? '') : '',
1171 - 'trip_excludes' => $trip ? ($trip->excludes ?? $trip->trip_excludes ?? '') : '',
1172 - 'departure_location' => $trip ? ($trip->departure_location ?? '') : '',
1173 - 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
1174 - 'travel_date' => $travelDate,
1175 - 'return_date' => $returnDate,
1176 - 'currency_symbol' => $currencySymbol,
1177 - 'total_amount' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
1178 - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
1179 - 'amount_due' => number_format((float) ($payment->booking_amount_due ?? 0), 2),
1180 - 'traveler_count' => (int) ($payment->traveler_count ?? 1),
1181 - ];
1371 + $pdfBinary = $builder->buildFromPaymentRecord($payment);
1182 1372
1183 - $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/itinerary.php', $templateData, [
1184 - 'paper' => 'A4',
1185 - 'orientation' => 'portrait',
1186 - 'default_font' => 'DejaVu Sans',
1187 - ]);
1188 -
1189 1373 if ($isPreview) {
1190 - // For preview, return PDF as inline display
1191 1374 return new WP_REST_Response([
1192 1375 'success' => true,
1193 1376 'pdf_data' => base64_encode($pdfBinary),
1194 1377 'filename' => $filename,
1195 1378 ]);
1196 - } else {
1197 - // For download, output PDF as download
1198 - $pdfService->outputPdfDownload($pdfBinary, $filename);
1199 - exit;
1200 1379 }
1380 +
1381 + $builder->pdfService()->outputPdfDownload($pdfBinary, $filename);
1382 + exit;
1201 1383 }
1202 1384
1203 1385 /**
1204 - * 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.
1205 1391 *
1206 - * The token is bound to the payment id + booking id and signed with the WP auth salt,
1207 - * so it cannot be forged without the site secret. It is safe to embed in the
1208 - * 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
1209 1411 * download their invoice without a session.
1210 1412 */
1211 1413 public static function issueInvoiceToken(int $paymentId, int $bookingId): string
1212 1414 {
1213 - 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) {
1214 1418 return '';
1215 1419 }
1216 - 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;
1217 1427 }
1218 1428
1219 1429 /**
1220 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.
1221 1439 */
1222 1440 public static function verifyInvoiceToken(string $token, int $paymentId, int $bookingId): bool
1223 1441 {
1224 - if ($token === '' || $paymentId <= 0 || $bookingId <= 0) {
1442 + // $paymentId === 0 = booking-scoped (pro-forma) token; see issueInvoiceToken().
1443 + if ($token === '' || $paymentId < 0 || $bookingId <= 0) {
1225 1444 return false;
1226 1445 }
1227 - $expected = self::issueInvoiceToken($paymentId, $bookingId);
1228 - 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);
1229 1484 }
1230 1485 }