PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.4
Yatra – Travel Booking & Tour Operator Software v3.0.4
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 2.0.10 2.0.11 All 82 releases
yatra / app / Controllers / PaymentGatewayController.php

PaymentGatewayController.php in Yatra – Travel Booking & Tour Operator Software 3.0.4, at app/Controllers/PaymentGatewayController.php

1,231 lines 53.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Controllers;
6
7 use WP_REST_Request;
8 use WP_REST_Response;
9 use WP_Error;
10 use Yatra\PaymentGateways\PaymentGatewayRegistry;
11 use Yatra\Repositories\BookingRepository;
12 use Yatra\Repositories\PaymentRepository;
13 use Yatra\Repositories\TripRepository;
14 use Yatra\Helpers\FormatHelper;
15 use Yatra\Services\PdfService;
16 use Yatra\Services\SettingsService;
17
18 /**
19 * Payment Gateway REST API Controller
20 *
21 * Handles payment gateway operations and payment processing
22 */
23 class PaymentGatewayController extends BaseController
24 {
25 private PaymentGatewayRegistry $registry;
26 private BookingRepository $bookingRepository;
27 private PaymentRepository $paymentRepository;
28 private TripRepository $tripRepository;
29
30 public function __construct()
31 {
32 $this->registry = PaymentGatewayRegistry::getInstance();
33 $this->bookingRepository = new BookingRepository();
34 $this->paymentRepository = new PaymentRepository();
35 $this->tripRepository = new TripRepository();
36 }
37
38 public function register_routes(): void
39 {
40 $namespace = 'yatra/v1';
41 $base = 'payment';
42
43 // Get gateway definitions for admin settings
44 register_rest_route($namespace, '/' . $base . '/gateways/definitions', [
45 [
46 'methods' => \WP_REST_Server::READABLE,
47 'callback' => [$this, 'get_gateway_definitions'],
48 'permission_callback' => [$this, 'check_admin_permission'],
49 ],
50 ]);
51
52 // Get available gateways for checkout
53 register_rest_route($namespace, '/' . $base . '/gateways', [
54 [
55 'methods' => \WP_REST_Server::READABLE,
56 'callback' => [$this, 'get_available_gateways'],
57 'permission_callback' => '__return_true',
58 ],
59 ]);
60
61 // Save gateway config
62 register_rest_route($namespace, '/' . $base . '/gateways/(?P<gateway_id>[a-z_]+)/config', [
63 [
64 'methods' => \WP_REST_Server::CREATABLE,
65 'callback' => [$this, 'save_gateway_config'],
66 'permission_callback' => [$this, 'check_admin_permission'],
67 ],
68 ]);
69
70 // Create payment intent
71 register_rest_route($namespace, '/' . $base . '/create-intent', [
72 [
73 'methods' => \WP_REST_Server::CREATABLE,
74 'callback' => [$this, 'create_payment_intent'],
75 'permission_callback' => '__return_true',
76 ],
77 ]);
78
79 // Confirm payment
80 register_rest_route($namespace, '/' . $base . '/confirm', [
81 [
82 'methods' => \WP_REST_Server::CREATABLE,
83 'callback' => [$this, 'confirm_payment'],
84 'permission_callback' => '__return_true',
85 ],
86 ]);
87
88 // Webhook handlers
89 register_rest_route($namespace, '/' . $base . '/webhook/(?P<gateway>[a-z_]+)', [
90 [
91 'methods' => \WP_REST_Server::CREATABLE,
92 'callback' => [$this, 'handle_webhook'],
93 'permission_callback' => '__return_true',
94 ],
95 ]);
96
97 // Payment callback (for redirect-based payments)
98 register_rest_route($namespace, '/' . $base . '/callback/(?P<gateway>[a-z_]+)', [
99 [
100 'methods' => \WP_REST_Server::READABLE,
101 'callback' => [$this, 'handle_callback'],
102 'permission_callback' => '__return_true',
103 ],
104 ]);
105
106 // Get payment status
107 register_rest_route($namespace, '/' . $base . '/status/(?P<booking_id>[\d]+)', [
108 [
109 'methods' => \WP_REST_Server::READABLE,
110 'callback' => [$this, 'get_payment_status'],
111 'permission_callback' => '__return_true',
112 ],
113 ]);
114
115 register_rest_route($namespace, '/' . $base . '/remaining', [
116 [
117 'methods' => \WP_REST_Server::CREATABLE,
118 'callback' => [$this, 'create_remaining_balance_intent'],
119 'permission_callback' => [$this, 'check_customer_permission'],
120 ],
121 ]);
122
123 register_rest_route($namespace, '/' . $base . '/remaining/session', [
124 [
125 'methods' => \WP_REST_Server::CREATABLE,
126 'callback' => [$this, 'start_remaining_payment_session'],
127 'permission_callback' => [$this, 'check_customer_permission'],
128 ],
129 ]);
130
131 // Download invoice for a payment
132 register_rest_route($namespace, '/' . $base . '/(?P<payment_id>[\d]+)/invoice', [
133 [
134 'methods' => \WP_REST_Server::READABLE,
135 'callback' => [$this, 'download_invoice'],
136 'permission_callback' => '__return_true', // Auth checked inside callback
137 ],
138 ]);
139
140 // Download travel voucher for a payment
141 register_rest_route($namespace, '/' . $base . '/(?P<payment_id>[\d]+)/voucher', [
142 [
143 'methods' => \WP_REST_Server::READABLE,
144 'callback' => [$this, 'download_voucher'],
145 'permission_callback' => '__return_true', // Auth checked inside callback
146 ],
147 ]);
148 }
149
150 /**
151 * Check admin permission
152 */
153 public function check_admin_permission(): bool
154 {
155 return current_user_can('manage_options');
156 }
157
158 public function check_customer_permission(): bool
159 {
160 return is_user_logged_in();
161 }
162
163 /**
164 * Get gateway definitions for admin settings
165 */
166 public function get_gateway_definitions(WP_REST_Request $request): WP_REST_Response
167 {
168 return new WP_REST_Response([
169 'gateways' => $this->registry->getDefinitions(),
170 'currency' => get_option('yatra_currency', 'USD'),
171 ], 200);
172 }
173
174 public function create_remaining_balance_intent(WP_REST_Request $request)
175 {
176 $bookingId = (int) $request->get_param('booking_id');
177 $method = sanitize_text_field($request->get_param('method') ?: 'stripe');
178
179 if ($bookingId <= 0) {
180 return new WP_Error('invalid_booking', __('Invalid booking ID provided.', 'yatra'), ['status' => 400]);
181 }
182
183 $booking = $this->bookingRepository->find($bookingId);
184
185 if (!$booking) {
186 return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
187 }
188
189 $currentUser = get_current_user_id();
190 if (!$currentUser || (int) $booking->user_id !== $currentUser) {
191 return new WP_Error('forbidden', __('You do not have permission to pay for this booking.', 'yatra'), ['status' => 403]);
192 }
193
194 $remainingAmount = (float) ($booking->amount_due ?? ($booking->total_amount - $booking->amount_paid));
195
196 if ($remainingAmount <= 0) {
197 return new WP_Error('no_balance_due', __('This booking is already fully paid.', 'yatra'), ['status' => 400]);
198 }
199
200 $customerEmail = $booking->contact_email ?? ($booking->customer_email ?? '');
201 $customerName = trim(($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''));
202
203 // Append `balance=paid` to the gateway's return URL so the booking-confirmation
204 // template can render a "balance just paid" banner instead of the generic "booking
205 // confirmed" copy. Same canonical URL — the flag only switches contextual content.
206 $confirmationUrl = $this->getConfirmationUrl($booking->reference ?? (string) $bookingId);
207 $confirmationUrl = add_query_arg('balance', 'paid', $confirmationUrl);
208
209 // Customer-account base is configurable under Settings → Permalink. Don't
210 // hardcode `/my-account` — that breaks for sites that have customised the slug.
211 $accountUrl = home_url('/' . SettingsService::getAccountBase());
212 $cancelUrl = add_query_arg(
213 ['tab' => 'payments', 'payment' => 'cancelled'],
214 $accountUrl
215 );
216
217 $paymentData = [
218 'amount' => $remainingAmount,
219 'currency' => $booking->currency ?? get_option('yatra_currency', 'USD'),
220 'booking_id' => $bookingId,
221 'customer_email' => $customerEmail,
222 'customer_name' => $customerName ?: $customerEmail,
223 'return_url' => $confirmationUrl,
224 'description' => sprintf(__('Remaining balance for Booking #%s', 'yatra'), $booking->reference ?? $bookingId),
225 'cancel_url' => $cancelUrl,
226 ];
227
228 $result = $this->registry->processPayment($method, $paymentData);
229
230 if (!$result['success']) {
231 $message = $result['error'] ?? $result['message'] ?? __('Unable to initiate payment.', 'yatra');
232 return new WP_Error('payment_error', $message, ['status' => 400]);
233 }
234
235 return new WP_REST_Response([ 'success' => true, 'data' => $result ], 200);
236 }
237
238 public function start_remaining_payment_session(WP_REST_Request $request)
239 {
240 if (!function_exists('yatra_start_session')) {
241 return new WP_Error('session_unavailable', __('Booking session helpers not loaded.', 'yatra'), ['status' => 500]);
242 }
243
244 yatra_start_session();
245
246 $bookingId = (int) $request->get_param('booking_id');
247
248 if ($bookingId <= 0) {
249 return new WP_Error('invalid_booking', __('Invalid booking ID provided.', 'yatra'), ['status' => 400]);
250 }
251
252 $booking = $this->bookingRepository->findWithTrip($bookingId);
253
254 if (!$booking) {
255 return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
256 }
257
258 $currentUser = get_current_user_id();
259
260 if (!$currentUser || (int) $booking->user_id !== $currentUser) {
261 return new WP_Error('forbidden', __('You do not have permission to pay for this booking.', 'yatra'), ['status' => 403]);
262 }
263
264 $remainingAmount = (float) ($booking->amount_due ?? ($booking->total_amount - $booking->amount_paid));
265
266 if ($remainingAmount <= 0) {
267 return new WP_Error('no_balance_due', __('This booking is already fully paid.', 'yatra'), ['status' => 400]);
268 }
269
270 $trip = $this->tripRepository->findPublished((int) $booking->trip_id);
271
272 if (!$trip) {
273 return new WP_Error('trip_not_found', __('Trip associated with this booking is unavailable.', 'yatra'), ['status' => 400]);
274 }
275
276 $currency = $booking->currency ?? SettingsService::getCurrency();
277 $travelersCount = (int) ($booking->travelers_count ?? $booking->travelers ?? 1);
278 $travelersCount = max(1, $travelersCount);
279 $pricePerPerson = $travelersCount > 0 ? ((float) $booking->total_amount / $travelersCount) : (float) $trip->sale_price;
280
281 // Use dedicated remaining session (separate from booking session)
282 $remainingSessionData = [
283 'booking_id' => (int) $booking->id,
284 'booking_reference' => $booking->reference ?? '',
285 'trip_id' => (int) $trip->id,
286 'trip_title' => $trip->title,
287 'trip_slug' => $trip->slug,
288 'trip_price' => $pricePerPerson,
289 'trip_featured_image' => $trip->featured_image ?? '',
290 'currency' => $currency,
291 'travel_date' => $booking->travel_date,
292 'travelers' => $travelersCount,
293 'remaining_amount' => $remainingAmount,
294 'amount_paid' => (float) ($booking->amount_paid ?? 0),
295 'total_amount' => (float) ($booking->total_amount ?? 0),
296 'contact_first_name' => $booking->contact_first_name ?? '',
297 'contact_last_name' => $booking->contact_last_name ?? '',
298 'contact_email' => $booking->contact_email ?? $booking->customer_email ?? '',
299 'contact_phone' => $booking->contact_phone ?? $booking->customer_phone ?? '',
300 ];
301
302 // Clear any existing booking session to avoid confusion
303 yatra_clear_booking_session();
304 // Set the remaining payment session
305 yatra_set_remaining_session($remainingSessionData);
306
307 $checkoutUrl = yatra_get_checkout_url();
308 // Custom booking page is a normal WP page: pass trip slug so embedded booking UI can resolve the trip.
309 if (!empty($trip->slug) && SettingsService::useCustomBookingPage()) {
310 $checkoutUrl = add_query_arg('trip', rawurlencode((string) $trip->slug), $checkoutUrl);
311 }
312
313 return new WP_REST_Response([
314 'success' => true,
315 'data' => [
316 'checkout_url' => $checkoutUrl,
317 'booking_reference' => $booking->reference ?? '',
318 'return_url' => $this->getConfirmationUrl($booking->reference ?? ''),
319 ],
320 ]);
321 }
322
323 /**
324 * Get available gateways for checkout.
325 *
326 * For the *remaining-balance* checkout (when `yatra_has_remaining_session()` is
327 * true OR the request explicitly carries `?context=remaining`), offline gateways
328 * are filtered out — Pay Later / Bank Transfer don't actually collect money, so
329 * picking them to "settle a balance" leaves the booking still unpaid and the
330 * customer thinking they finished the flow. Filterable via
331 * `yatra_remaining_payment_allowed_gateways` if a site needs custom behaviour.
332 */
333 public function get_available_gateways(WP_REST_Request $request): WP_REST_Response
334 {
335 $gateways = $this->registry->getForCheckout();
336
337 $context = sanitize_key((string) ($request->get_param('context') ?? ''));
338 $isRemainingFlow = $context === 'remaining'
339 || (function_exists('yatra_has_remaining_session') && yatra_has_remaining_session());
340
341 if ($isRemainingFlow) {
342 $gateways = array_values(array_filter($gateways, static function ($gw) {
343 return empty($gw['is_offline']);
344 }));
345
346 /**
347 * Filter the gateway list shown in the remaining-balance checkout.
348 *
349 * Default: every offline gateway (Pay Later, Bank Transfer, etc.) is
350 * removed so the customer can only pick a real-money method.
351 *
352 * @param array $gateways Gateway entries (id, title, is_offline, …).
353 */
354 $gateways = apply_filters('yatra_remaining_payment_allowed_gateways', $gateways);
355 }
356
357 return new WP_REST_Response([
358 'gateways' => $gateways,
359 'currency' => get_option('yatra_currency', 'USD'),
360 'context' => $isRemainingFlow ? 'remaining' : 'initial',
361 ], 200);
362 }
363
364 /**
365 * Save gateway configuration
366 */
367 public function save_gateway_config(WP_REST_Request $request)
368 {
369 $gatewayId = $request->get_param('gateway_id');
370 $config = $request->get_json_params();
371
372 $gateway = $this->registry->get($gatewayId);
373 if (!$gateway) {
374 return new WP_Error('invalid_gateway', __('Gateway not found', 'yatra'), ['status' => 404]);
375 }
376
377 $saved = $gateway->saveConfig($config);
378
379 if ($saved) {
380 /**
381 * Fires after a payment gateway configuration is saved (telemetry / integrations).
382 *
383 * @param string $gatewayId Gateway id.
384 * @param array<string, mixed> $config Sanitized-bound request body.
385 */
386 do_action('yatra_payment_gateway_config_saved', (string) $gatewayId, is_array($config) ? $config : []);
387 }
388
389 return new WP_REST_Response([
390 'success' => $saved,
391 'message' => $saved ? __('Gateway configuration saved', 'yatra') : __('Failed to save configuration', 'yatra'),
392 ], $saved ? 200 : 500);
393 }
394
395 /**
396 * Create payment intent
397 */
398 public function create_payment_intent(WP_REST_Request $request)
399 {
400 $gatewayId = sanitize_text_field($request->get_param('gateway'));
401 $paymentData = [
402 'amount' => (float) $request->get_param('amount'),
403 'currency' => sanitize_text_field($request->get_param('currency') ?: get_option('yatra_currency', 'USD')),
404 'booking_id' => (int) $request->get_param('booking_id'),
405 'trip_id' => (int) $request->get_param('trip_id'),
406 'trip_date' => sanitize_text_field($request->get_param('trip_date') ?? ''),
407 'customer_email' => sanitize_email($request->get_param('customer_email')),
408 'customer_name' => sanitize_text_field($request->get_param('customer_name')),
409 'return_url' => esc_url_raw($request->get_param('return_url')),
410 ];
411
412 // Enrich payment data with booking context (reference, trip title, cancel URL).
413 // SECURITY: when a booking_id is supplied, the authoritative amount/currency must come
414 // from the database row, NOT from the client. Otherwise an attacker can pay $1 for a
415 // $1000 trip by tampering with the JSON body.
416 if ($paymentData['booking_id'] > 0) {
417 $booking = $this->bookingRepository->find($paymentData['booking_id']);
418 if (!$booking) {
419 return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
420 }
421
422 // If the booking is owned by a registered user, only that user (or an admin) may pay it.
423 // Guest bookings (user_id = 0) remain payable without auth — the booking session controls access.
424 $bookingUserId = (int) ($booking->user_id ?? 0);
425 if ($bookingUserId > 0) {
426 $currentUserId = (int) get_current_user_id();
427 if ($currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
428 return new WP_Error('forbidden', __('You do not have permission to pay for this booking.', 'yatra'), ['status' => 403]);
429 }
430 }
431
432 // Reject already-paid bookings to prevent duplicate intents.
433 if (isset($booking->payment_status) && $booking->payment_status === 'paid') {
434 return new WP_Error('already_paid', __('This booking is already fully paid.', 'yatra'), ['status' => 400]);
435 }
436
437 // Server-authoritative amount/currency. Use amount_due, falling back to total - paid for older rows.
438 $serverAmount = (float) ($booking->amount_due ?? ($booking->total_amount - $booking->amount_paid));
439 $serverCurrency = (string) ($booking->currency ?? get_option('yatra_currency', 'USD'));
440
441 if ($serverAmount <= 0) {
442 return new WP_Error('no_balance_due', __('This booking has no outstanding balance.', 'yatra'), ['status' => 400]);
443 }
444
445 // Tolerate sub-cent rounding drift only.
446 if (abs($paymentData['amount'] - $serverAmount) > 0.01) {
447 $this->log_amount_mismatch((int) $booking->id, $paymentData['amount'], $serverAmount);
448 }
449
450 // Always overwrite with server values regardless of what the client sent.
451 $paymentData['amount'] = $serverAmount;
452 $paymentData['currency'] = $serverCurrency;
453 $paymentData['reference'] = $booking->reference ?? '';
454 $paymentData['trip_title'] = $booking->trip_title ?? '';
455 if (empty($paymentData['trip_id'])) {
456 $paymentData['trip_id'] = (int) ($booking->trip_id ?? 0);
457 }
458 }
459
460 if (empty($paymentData['return_url'])) {
461 $reference = $paymentData['reference'] ?? (string) $paymentData['booking_id'];
462 $paymentData['return_url'] = add_query_arg('payment', 'success', $this->getConfirmationUrl($reference));
463 }
464
465 $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']));
467
468 if ($paymentData['amount'] <= 0) {
469 return new WP_Error('invalid_amount', __('Invalid payment amount', 'yatra'), ['status' => 400]);
470 }
471
472 $result = $this->registry->processPayment($gatewayId, $paymentData);
473
474 if (!$result['success']) {
475 $errorMessage = $result['error'] ?? $result['message'] ?? __('Payment failed', 'yatra');
476 return new WP_Error('payment_error', $errorMessage, ['status' => 400]);
477 }
478
479 return new WP_REST_Response($result, 200);
480 }
481
482 private function getConfirmationUrl(string $reference): string
483 {
484 return yatra_get_booking_confirmation_url($reference);
485 }
486
487 /**
488 * Record an attempted payment-amount mismatch (client sent X, server expects Y).
489 * The transaction itself is forced to the server amount; this exists for fraud monitoring.
490 */
491 private function log_amount_mismatch(int $bookingId, float $clientAmount, float $serverAmount): void
492 {
493 if (defined('WP_DEBUG') && WP_DEBUG) {
494 error_log(sprintf(
495 '[Yatra] Payment amount mismatch for booking %d: client=%.4f server=%.4f',
496 $bookingId,
497 $clientAmount,
498 $serverAmount
499 ));
500 }
501
502 /**
503 * Fires when a client-supplied payment amount disagrees with the server-side booking amount.
504 * Useful for fraud-monitoring integrations.
505 */
506 do_action('yatra_payment_amount_mismatch', $bookingId, $clientAmount, $serverAmount);
507 }
508
509 /**
510 * Confirm payment
511 */
512 public function confirm_payment(WP_REST_Request $request)
513 {
514 $gatewayId = sanitize_text_field($request->get_param('gateway'));
515 $transactionId = sanitize_text_field($request->get_param('transaction_id'));
516 $bookingId = (int) $request->get_param('booking_id');
517 $saveCard = !empty($request->get_param('save_card'));
518
519 $gateway = $this->registry->get($gatewayId);
520 if (!$gateway) {
521 return new WP_Error('invalid_gateway', __('Gateway not found', 'yatra'), ['status' => 404]);
522 }
523
524 if ($bookingId <= 0 || $transactionId === '') {
525 return new WP_Error('invalid_request', __('booking_id and transaction_id are required.', 'yatra'), ['status' => 400]);
526 }
527
528 // Resolve the booking up front so we can enforce ownership BEFORE confirming a charge against it.
529 // Without this check, an anonymous attacker could mark booking B as paid by replaying a successful
530 // transaction_id that actually belongs to booking A.
531 $booking = $this->bookingRepository->find($bookingId);
532 if (!$booking) {
533 return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
534 }
535
536 $bookingUserId = (int) ($booking->user_id ?? 0);
537 if ($bookingUserId > 0) {
538 $currentUserId = (int) get_current_user_id();
539 if ($currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
540 return new WP_Error('forbidden', __('You do not have permission to confirm this payment.', 'yatra'), ['status' => 403]);
541 }
542 }
543
544 // Idempotency: if we have already recorded this transaction, return the cached verification result
545 // without re-applying the payment. Prevents duplicate ledger rows and double-confirmed bookings
546 // when the user reloads the confirmation page.
547 $existing = $this->paymentRepository->findByTransactionId($transactionId);
548 if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) {
549 return new WP_REST_Response([
550 'success' => true,
551 'status' => $existing->status ?? 'completed',
552 'amount' => (float) ($existing->amount ?? 0),
553 'currency' => $existing->currency ?? null,
554 'transaction_id' => $transactionId,
555 'idempotent' => true,
556 ], 200);
557 }
558
559 // If a payment with this transaction id is already attached to a DIFFERENT booking, refuse —
560 // someone is trying to reuse a stranger's transaction to pay their own booking.
561 if ($existing && (int) ($existing->booking_id ?? 0) !== $bookingId) {
562 return new WP_Error('transaction_mismatch', __('Transaction does not belong to this booking.', 'yatra'), ['status' => 409]);
563 }
564
565 $result = $gateway->verifyPayment($transactionId);
566
567 if ($result['success']) {
568 // Get customer and payment method from result
569 $customerId = $result['customer_id'] ?? null;
570 $paymentMethodId = $result['payment_method_id'] ?? $result['token_id'] ?? $result['vault_id'] ?? null;
571
572 $passForSchedule = (bool) apply_filters(
573 'yatra_pass_gateway_ids_for_scheduled_payments',
574 $saveCard,
575 $result,
576 $bookingId
577 );
578
579 $this->handle_successful_payment(
580 $bookingId,
581 $gatewayId,
582 $transactionId,
583 $result['amount'] ?? null,
584 $result['currency'] ?? null,
585 ($saveCard || $passForSchedule) ? $customerId : null,
586 ($saveCard || $passForSchedule) ? $paymentMethodId : null
587 );
588 }
589
590 return new WP_REST_Response($result, 200);
591 }
592
593 /**
594 * Handle webhook
595 */
596 public function handle_webhook(WP_REST_Request $request)
597 {
598 $gatewayId = $request->get_param('gateway');
599 $gateway = $this->registry->get($gatewayId);
600
601 if (!$gateway) {
602 return new WP_Error('invalid_gateway', __('Gateway not found', 'yatra'), ['status' => 404]);
603 }
604
605 $data = $request->get_json_params() ?: [];
606 $data['raw_body'] = $request->get_body();
607 $data['headers'] = $request->get_headers();
608 $data['post_data'] = $request->get_body_params(); // For form-encoded data (like PayPal IPN)
609
610 $result = $gateway->handleWebhook($data);
611
612 return new WP_REST_Response($result, 200);
613 }
614
615 /**
616 * Handle callback (for redirect-based payments)
617 */
618 public function handle_callback(WP_REST_Request $request): void
619 {
620 $gatewayId = $request->get_param('gateway');
621 $bookingId = (int) $request->get_param('booking_id');
622 $status = $request->get_param('status');
623
624 $gateway = $this->registry->get($gatewayId);
625
626 if (!$gateway) {
627 wp_redirect(home_url('/booking-failed/'));
628 exit;
629 }
630
631 // Get transaction ID from request (varies by gateway)
632 $transactionId = $request->get_param('refId')
633 ?? $request->get_param('pidx')
634 ?? $request->get_param('transaction_id')
635 ?? '';
636
637 if ($status === 'success' && !empty($transactionId)) {
638 $result = $gateway->verifyPayment($transactionId);
639
640 if ($result['success']) {
641 $this->handle_successful_payment($bookingId, $gatewayId, $transactionId);
642 wp_redirect(home_url('/booking-success/?booking_id=' . $bookingId));
643 exit;
644 }
645 }
646
647 wp_redirect(home_url('/booking-failed/'));
648 exit;
649 }
650
651 /**
652 * Get payment status
653 *
654 * Endpoint is public (`__return_true` permission) so guest checkouts can poll. Authorisation
655 * is enforced inline: registered-user bookings require the owning user (or an admin); guest
656 * bookings additionally require a matching short-lived booking_token transient so a stranger
657 * can't enumerate booking IDs to harvest payment metadata.
658 */
659 public function get_payment_status(WP_REST_Request $request)
660 {
661 $bookingId = (int) $request->get_param('booking_id');
662 $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? ''));
663
664 if ($bookingId <= 0) {
665 return new WP_Error('invalid_booking', __('Invalid booking ID.', 'yatra'), ['status' => 400]);
666 }
667
668 $payment = $this->paymentRepository->findLatestByBookingId($bookingId);
669
670 if (!$payment) {
671 return new WP_Error('payment_not_found', __('Payment not found', 'yatra'), ['status' => 404]);
672 }
673
674 $booking = $this->bookingRepository->find($bookingId);
675 $bookingUserId = $booking ? (int) ($booking->user_id ?? 0) : 0;
676 $currentUserId = (int) get_current_user_id();
677 $authorised = false;
678
679 if (current_user_can('manage_options')) {
680 $authorised = true;
681 } elseif ($bookingUserId > 0 && $currentUserId === $bookingUserId) {
682 $authorised = true;
683 } elseif ($bookingUserId === 0 && $bookingToken !== '') {
684 // Guest booking: require the booking-session transient to prove the requester is the
685 // browser that started this checkout.
686 $session = get_transient($bookingToken);
687 if (is_array($session) && (int) ($session['booking_id'] ?? 0) === $bookingId) {
688 $authorised = true;
689 }
690 }
691
692 if (!$authorised) {
693 if ($currentUserId > 0) {
694 return new WP_Error('forbidden', __('You do not have permission to view this payment.', 'yatra'), ['status' => 403]);
695 }
696 return new WP_Error('unauthorized', __('Authentication required.', 'yatra'), ['status' => 401]);
697 }
698
699 return new WP_REST_Response([
700 'status' => $payment->status,
701 'amount' => (float) $payment->amount,
702 'currency' => $payment->currency,
703 'gateway' => $payment->payment_gateway ?? $payment->gateway ?? '',
704 'transaction_id' => $payment->transaction_id,
705 'created_at' => $payment->created_at,
706 ], 200);
707 }
708
709 /**
710 * Record a completed charge against an existing booking (initial or remaining balance).
711 * Does not create bookings — only PaymentRepository::create + booking amount/status updates.
712 */
713 private function handle_successful_payment(
714 int $bookingId,
715 string $gateway,
716 string $transactionId,
717 ?float $amount = null,
718 ?string $currency = null,
719 ?string $customerId = null,
720 ?string $paymentMethodId = null
721 ): void {
722 if ($bookingId <= 0) {
723 return;
724 }
725
726 // Get booking details
727 $booking = $this->bookingRepository->find($bookingId);
728
729 if (!$booking) {
730 return;
731 }
732
733 $paid_amount = $amount ?? (float) $booking->amount_due;
734 $payment_currency = $currency ?? $booking->currency;
735
736 // Idempotency guard: skip if we have already recorded this gateway transaction for this booking.
737 // Prevents double-applied payments when both confirm_payment and the gateway's own return-handler
738 // (or a webhook) fire for the same charge.
739 if ($transactionId !== '') {
740 $existing = $this->paymentRepository->findByTransactionId($transactionId);
741 if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) {
742 return;
743 }
744 }
745
746 $payment_data = [
747 'booking_id' => $bookingId,
748 'gateway' => $gateway,
749 'transaction_id' => $transactionId,
750 'amount' => $paid_amount,
751 'currency' => $payment_currency,
752 'status' => 'completed',
753 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
754 ];
755
756 // Create or update payment record
757 $this->paymentRepository->create($payment_data);
758
759 // Calculate new amounts
760 $new_amount_paid = (float) $booking->amount_paid + $paid_amount;
761 $new_amount_due = max(0, (float) $booking->total_amount - $new_amount_paid);
762
763 // Determine payment status
764 $payment_status = 'paid';
765 if ($new_amount_due > 0) {
766 $payment_status = 'partial';
767 }
768
769 $previousBookingStatus = (string) ($booking->status ?? 'pending');
770
771 // Update booking
772 $this->bookingRepository->update($bookingId, [
773 'amount_paid' => $new_amount_paid,
774 'amount_due' => $new_amount_due,
775 'payment_status' => $payment_status,
776 'status' => 'confirmed',
777 ]);
778
779 \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus);
780
781 // Clear remaining payment session if this was a remaining payment
782 if (function_exists('yatra_has_remaining_session') && yatra_has_remaining_session()) {
783 yatra_clear_remaining_session();
784 }
785
786 do_action('yatra_payment_completed', $bookingId, $gateway, $transactionId, [
787 'amount' => $paid_amount,
788 'remaining' => $new_amount_due,
789 'customer_id' => $customerId,
790 'payment_method_id' => $paymentMethodId,
791 ]);
792 }
793
794 /**
795 * Download invoice PDF for a payment
796 */
797 public function download_invoice(WP_REST_Request $request)
798 {
799 $paymentId = (int) $request->get_param('payment_id');
800 $isPreview = $request->get_param('preview') === '1';
801 $isDownload = $request->get_param('download') === '1';
802 $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? ''));
803 $invoiceToken = sanitize_text_field((string) ($request->get_param('invoice_token') ?? ''));
804
805 if ($paymentId <= 0) {
806 return new WP_Error('invalid_payment', __('Invalid payment ID.', 'yatra'), ['status' => 400]);
807 }
808
809 // Get payment with booking details
810 $payment = $this->paymentRepository->findWithBooking($paymentId);
811
812 if (!$payment) {
813 return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]);
814 }
815
816 // Authorisation:
817 // 1. Administrators can always access (no further checks).
818 // 2. Logged-in owner of the booking can access.
819 // 3. Anyone with a valid signed `invoice_token` (HMAC) can access — used on the
820 // booking-confirmation page so guest checkouts and post-session views work.
821 // 4. Legacy guest path: `booking_token` (active checkout transient) — kept for BC.
822 $currentUserId = (int) get_current_user_id();
823 $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0);
824 $paymentBookingId = (int) ($payment->booking_id ?? 0);
825 $isAdmin = current_user_can('manage_options');
826 $authorised = false;
827
828 if ($isAdmin) {
829 $authorised = true;
830 } elseif ($currentUserId && $bookingUserId && $currentUserId === $bookingUserId) {
831 $authorised = true;
832 } elseif ($invoiceToken !== '' && self::verifyInvoiceToken($invoiceToken, (int) $payment->id, $paymentBookingId)) {
833 $authorised = true;
834 } elseif ($bookingToken !== '') {
835 $guestEnabled = (bool) SettingsService::get('allow_guest_checkout', true);
836 if ($guestEnabled) {
837 $session = get_transient($bookingToken);
838 if (is_array($session)) {
839 $sessionBookingId = (int) ($session['booking_id'] ?? 0);
840 if ($sessionBookingId > 0 && $paymentBookingId > 0 && $sessionBookingId === $paymentBookingId) {
841 $authorised = true;
842 }
843 }
844 }
845 }
846
847 if (!$authorised) {
848 if ($currentUserId) {
849 return new WP_Error('forbidden', __('You do not have permission to access this invoice.', 'yatra'), ['status' => 403]);
850 }
851 return new WP_Error('unauthorized', __('You must be logged in to download invoices.', 'yatra'), ['status' => 401]);
852 }
853
854 // Get trip details if available
855 $trip = null;
856 if (!empty($payment->trip_id)) {
857 $trip = $this->tripRepository->find((int) $payment->trip_id);
858 }
859
860 // Get company settings
861 $companyName = SettingsService::get('company_name', get_bloginfo('name'));
862 $companyAddress = SettingsService::get('company_address', '');
863 $companyEmail = SettingsService::get('company_email', get_option('admin_email'));
864 $companyPhone = SettingsService::get('company_phone', '');
865 $currency = SettingsService::getCurrency();
866 $currencySymbol = FormatHelper::getCurrencySymbol($currency);
867
868 // Format dates
869 $paymentDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
870 $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
871
872
873 $bookingRef = (string) ($payment->booking_reference ?? $payment->booking_number ?? $payment->reference ?? (string) $paymentId);
874 $filename = 'Invoice #' . $bookingRef . '.pdf';
875
876
877 $pdfService = new PdfService();
878 if (!$pdfService->isAvailable()) {
879 return new WP_Error(
880 'pdf_engine_missing',
881 __('Invoice PDF generator is not installed. Please run composer install to install dompdf/dompdf.', 'yatra'),
882 ['status' => 500]
883 );
884 }
885
886 // Get tax breakdown for invoice
887 $tax_breakdown = [];
888 $tax_amount = 0;
889 $subtotal = (float) ($payment->booking_total_amount ?? $payment->amount ?? 0);
890
891 if (!empty($payment->tax_details)) {
892 $taxes = json_decode($payment->tax_details, true) ?: [];
893 foreach ($taxes as $tax) {
894 $tax_amount += (float) ($tax['amount'] ?? 0);
895 $tax_breakdown[] = [
896 'name' => $tax['name'] ?? 'Tax',
897 'rate' => $tax['rate'] ?? 0,
898 'amount' => $tax['amount'] ?? 0
899 ];
900 }
901 // Adjust subtotal for tax-exclusive pricing
902 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
903 $subtotal = (float) ($payment->subtotal ?? $subtotal);
904 }
905 } elseif (!empty($payment->tax_amount) && $payment->tax_amount > 0) {
906 // Single tax fallback
907 $tax_amount = (float) $payment->tax_amount;
908 $tax_breakdown[] = [
909 'name' => __('Tax', 'yatra'),
910 'rate' => (float) ($payment->tax_rate ?? 0),
911 'amount' => $tax_amount
912 ];
913 // Adjust subtotal for tax-exclusive pricing
914 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
915 $subtotal = (float) ($payment->subtotal ?? $subtotal);
916 } else {
917 $subtotal = (float) ($payment->subtotal ?? ($subtotal - $tax_amount));
918 }
919 }
920
921 $templateData = [
922 'company_name' => $companyName,
923 'company_address' => $companyAddress,
924 'company_email' => $companyEmail,
925 'company_phone' => $companyPhone,
926 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
927 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
928 'payment_ref' => $payment->reference ?? '',
929 'payment_date' => $paymentDate,
930 'payment_status' => ucfirst($payment->status ?? 'paid'),
931 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['paid', 'completed', 'success'], true) ? 'paid' : 'pending',
932 '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 ?? '',
935 'travel_date' => $travelDate,
936 '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),
941 'tax_breakdown' => $tax_breakdown,
942 'tax_amount' => number_format($tax_amount, 2),
943 'subtotal' => number_format($subtotal, 2),
944 ];
945
946 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/invoice.php', $templateData, [
947 'paper' => 'A4',
948 'orientation' => 'portrait',
949 'default_font' => 'DejaVu Sans',
950 ]);
951
952 if ($isPreview) {
953 // For preview, return PDF as inline display
954 return new WP_REST_Response([
955 'success' => true,
956 'pdf_data' => base64_encode($pdfBinary),
957 'filename' => $filename,
958 ]);
959 } else {
960 // For download, output PDF as download
961 $pdfService->outputPdfDownload($pdfBinary, $filename);
962 exit;
963 }
964 }
965
966 /**
967 * Download travel voucher PDF for a booking
968 */
969 public function download_voucher(WP_REST_Request $request)
970 {
971 $paymentId = (int) $request->get_param('payment_id');
972 $isPreview = $request->get_param('preview') === '1';
973 $isDownload = $request->get_param('download') === '1';
974
975 if ($paymentId <= 0) {
976 return new WP_Error('invalid_payment', __('Invalid payment ID.', 'yatra'), ['status' => 400]);
977 }
978
979 // Get payment with booking details
980 $payment = $this->paymentRepository->findWithBooking($paymentId);
981
982 if (!$payment) {
983 return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]);
984 }
985
986 // Verify user is logged in and owns this payment (or is admin)
987 $currentUserId = get_current_user_id();
988 $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0);
989
990 // Must be logged in
991 if (!$currentUserId) {
992 return new WP_Error('unauthorized', __('You must be logged in to download vouchers.', 'yatra'), ['status' => 401]);
993 }
994
995 // Must own the booking or be admin
996 if ($bookingUserId && $currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
997 return new WP_Error('forbidden', __('You do not have permission to access this voucher.', 'yatra'), ['status' => 403]);
998 }
999
1000 // Get trip details if available
1001 $trip = null;
1002 if (!empty($payment->trip_id)) {
1003 $trip = $this->tripRepository->find((int) $payment->trip_id);
1004 }
1005
1006 // Get company settings
1007 $companyName = SettingsService::get('company_name', get_bloginfo('name'));
1008 $companyAddress = SettingsService::get('company_address', '');
1009 $companyEmail = SettingsService::get('company_email', get_option('admin_email'));
1010 $companyPhone = SettingsService::get('company_phone', '');
1011 $currency = SettingsService::getCurrency();
1012 $currencySymbol = FormatHelper::getCurrencySymbol($currency);
1013
1014 // Format dates
1015 $bookingDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
1016 $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
1017
1018 // Calculate return date if duration is available
1019 $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);
1023 }
1024
1025 $bookingRef = (string) ($payment->booking_reference ?? $payment->booking_number ?? $payment->reference ?? (string) $paymentId);
1026 $filename = 'Travel Voucher #' . $bookingRef . '.pdf';
1027
1028 $pdfService = new PdfService();
1029 if (!$pdfService->isAvailable()) {
1030 return new WP_Error(
1031 'pdf_engine_missing',
1032 __('Voucher PDF generator is not installed. Please run composer install to install dompdf/dompdf.', 'yatra'),
1033 ['status' => 500]
1034 );
1035 }
1036
1037 $templateData = [
1038 'company_name' => $companyName,
1039 'company_address' => $companyAddress,
1040 'company_email' => $companyEmail,
1041 'company_phone' => $companyPhone,
1042 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
1043 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
1044 'booking_ref' => $bookingRef,
1045 'booking_date' => $bookingDate,
1046 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
1047 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
1048 (in_array(strtolower((string) ($payment->status ?? '')), ['cancelled'], true) ? 'cancelled' : 'pending'),
1049 '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) : '',
1051 'trip_difficulty' => $trip ? ($trip->difficulty_name ?? '') : '',
1052 'departure_location' => $trip ? ($trip->departure_location ?? '') : '',
1053 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
1054 'travel_date' => $travelDate,
1055 'return_date' => $returnDate,
1056 '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),
1060 'traveler_count' => (int) ($payment->traveler_count ?? 1),
1061 ];
1062
1063 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/voucher.php', $templateData, [
1064 'paper' => 'A4',
1065 'orientation' => 'portrait',
1066 'default_font' => 'DejaVu Sans',
1067 ]);
1068
1069 if ($isPreview) {
1070 // For preview, return PDF as inline display
1071 return new WP_REST_Response([
1072 'success' => true,
1073 'pdf_data' => base64_encode($pdfBinary),
1074 'filename' => $filename,
1075 ]);
1076 } else {
1077 // For download, output PDF as download
1078 $pdfService->outputPdfDownload($pdfBinary, $filename);
1079 exit;
1080 }
1081 }
1082
1083 /**
1084 * GET /payments/{payment_id}/itinerary - Download travel itinerary for a payment
1085 */
1086 public function download_itinerary(WP_REST_Request $request)
1087 {
1088 $paymentId = (int) $request->get_param('payment_id');
1089 $isPreview = $request->get_param('preview') === '1';
1090 $isDownload = $request->get_param('download') === '1';
1091
1092 if ($paymentId <= 0) {
1093 return new WP_Error('invalid_payment', __('Invalid payment ID.', 'yatra'), ['status' => 400]);
1094 }
1095
1096 // Get payment with booking details
1097 $payment = $this->paymentRepository->findWithBooking($paymentId);
1098
1099 if (!$payment) {
1100 return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]);
1101 }
1102
1103 // Verify user is logged in and owns this payment (or is admin)
1104 $currentUserId = get_current_user_id();
1105 $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0);
1106
1107 // Must be logged in
1108 if (!$currentUserId) {
1109 return new WP_Error('unauthorized', __('You must be logged in to download itineraries.', 'yatra'), ['status' => 401]);
1110 }
1111
1112 // Must own the booking or be admin
1113 if ($bookingUserId && $currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
1114 return new WP_Error('forbidden', __('You do not have permission to access this itinerary.', 'yatra'), ['status' => 403]);
1115 }
1116
1117 // Get trip details if available
1118 $trip = null;
1119 if (!empty($payment->trip_id)) {
1120 $trip = $this->tripRepository->find((int) $payment->trip_id);
1121 }
1122
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();
1150 $filename = 'Travel-Itinerary-' . $bookingRef . '.pdf';
1151
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 ];
1182
1183 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/itinerary.php', $templateData, [
1184 'paper' => 'A4',
1185 'orientation' => 'portrait',
1186 'default_font' => 'DejaVu Sans',
1187 ]);
1188
1189 if ($isPreview) {
1190 // For preview, return PDF as inline display
1191 return new WP_REST_Response([
1192 'success' => true,
1193 'pdf_data' => base64_encode($pdfBinary),
1194 'filename' => $filename,
1195 ]);
1196 } else {
1197 // For download, output PDF as download
1198 $pdfService->outputPdfDownload($pdfBinary, $filename);
1199 exit;
1200 }
1201 }
1202
1203 /**
1204 * Issue a stateless, signed token that grants access to a single payment's invoice.
1205 *
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
1209 * download their invoice without a session.
1210 */
1211 public static function issueInvoiceToken(int $paymentId, int $bookingId): string
1212 {
1213 if ($paymentId <= 0 || $bookingId <= 0) {
1214 return '';
1215 }
1216 return hash_hmac('sha256', $paymentId . '|' . $bookingId, wp_salt('auth') . '|yatra_invoice');
1217 }
1218
1219 /**
1220 * Verify a token previously issued by self::issueInvoiceToken().
1221 */
1222 public static function verifyInvoiceToken(string $token, int $paymentId, int $bookingId): bool
1223 {
1224 if ($token === '' || $paymentId <= 0 || $bookingId <= 0) {
1225 return false;
1226 }
1227 $expected = self::issueInvoiceToken($paymentId, $bookingId);
1228 return $expected !== '' && hash_equals($expected, $token);
1229 }
1230 }
1231