PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.8
Yatra – Travel Booking & Tour Operator Software v3.0.8
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.8, at app/Controllers/PaymentGatewayController.php

1,264 lines 53.7 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 * Payment gateway config — critical-sensitivity cap. By default
152 * only the Owner role holds `yatra_manage_payment_gateways`
153 * (Manager doesn't, deliberately — gateway keys are among the
154 * most sensitive credentials on the site). WP admins pass via
155 * the Team module's admin-fallback filter.
156 */
157 public function check_admin_permission(): bool
158 {
159 return current_user_can('yatra_manage_payment_gateways');
160 }
161
162 public function check_customer_permission(): bool
163 {
164 return is_user_logged_in();
165 }
166
167 /**
168 * Get gateway definitions for admin settings
169 */
170 public function get_gateway_definitions(WP_REST_Request $request): WP_REST_Response
171 {
172 return new WP_REST_Response([
173 'gateways' => $this->registry->getDefinitions(),
174 'currency' => get_option('yatra_currency', 'USD'),
175 ], 200);
176 }
177
178 public function create_remaining_balance_intent(WP_REST_Request $request)
179 {
180 $bookingId = (int) $request->get_param('booking_id');
181 $method = sanitize_text_field($request->get_param('method') ?: 'stripe');
182
183 if ($bookingId <= 0) {
184 return new WP_Error('invalid_booking', __('Invalid booking ID provided.', 'yatra'), ['status' => 400]);
185 }
186
187 $booking = $this->bookingRepository->find($bookingId);
188
189 if (!$booking) {
190 return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
191 }
192
193 $currentUser = get_current_user_id();
194 if (!$currentUser || (int) $booking->user_id !== $currentUser) {
195 return new WP_Error('forbidden', __('You do not have permission to pay for this booking.', 'yatra'), ['status' => 403]);
196 }
197
198 $remainingAmount = (float) ($booking->amount_due ?? ($booking->total_amount - $booking->amount_paid));
199
200 if ($remainingAmount <= 0) {
201 return new WP_Error('no_balance_due', __('This booking is already fully paid.', 'yatra'), ['status' => 400]);
202 }
203
204 $customerEmail = $booking->contact_email ?? ($booking->customer_email ?? '');
205 $customerName = trim(($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''));
206
207 // Append `balance=paid` to the gateway's return URL so the booking-confirmation
208 // template can render a "balance just paid" banner instead of the generic "booking
209 // confirmed" copy. Same canonical URL — the flag only switches contextual content.
210 $confirmationUrl = $this->getConfirmationUrl($booking->reference ?? (string) $bookingId);
211 $confirmationUrl = add_query_arg('balance', 'paid', $confirmationUrl);
212
213 // Customer-account base is configurable under Settings → Permalink. Don't
214 // hardcode `/my-account` — that breaks for sites that have customised the slug.
215 $accountUrl = home_url('/' . SettingsService::getAccountBase());
216 $cancelUrl = add_query_arg(
217 ['tab' => 'payments', 'payment' => 'cancelled'],
218 $accountUrl
219 );
220
221 $paymentData = [
222 'amount' => $remainingAmount,
223 'currency' => $booking->currency ?? get_option('yatra_currency', 'USD'),
224 'booking_id' => $bookingId,
225 'customer_email' => $customerEmail,
226 'customer_name' => $customerName ?: $customerEmail,
227 'return_url' => $confirmationUrl,
228 'description' => sprintf(
229 /* translators: %s: booking reference. */
230 __('Remaining balance for Booking #%s', 'yatra'),
231 $booking->reference ?? $bookingId
232 ),
233 'cancel_url' => $cancelUrl,
234 ];
235
236 $result = $this->registry->processPayment($method, $paymentData);
237
238 if (!$result['success']) {
239 $message = $result['error'] ?? $result['message'] ?? __('Unable to initiate payment.', 'yatra');
240 return new WP_Error('payment_error', $message, ['status' => 400]);
241 }
242
243 return new WP_REST_Response([ 'success' => true, 'data' => $result ], 200);
244 }
245
246 public function start_remaining_payment_session(WP_REST_Request $request)
247 {
248 if (!function_exists('yatra_start_session')) {
249 return new WP_Error('session_unavailable', __('Booking session helpers not loaded.', 'yatra'), ['status' => 500]);
250 }
251
252 yatra_start_session();
253
254 $bookingId = (int) $request->get_param('booking_id');
255
256 if ($bookingId <= 0) {
257 return new WP_Error('invalid_booking', __('Invalid booking ID provided.', 'yatra'), ['status' => 400]);
258 }
259
260 $booking = $this->bookingRepository->findWithTrip($bookingId);
261
262 if (!$booking) {
263 return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
264 }
265
266 $currentUser = get_current_user_id();
267
268 if (!$currentUser || (int) $booking->user_id !== $currentUser) {
269 return new WP_Error('forbidden', __('You do not have permission to pay for this booking.', 'yatra'), ['status' => 403]);
270 }
271
272 $remainingAmount = (float) ($booking->amount_due ?? ($booking->total_amount - $booking->amount_paid));
273
274 if ($remainingAmount <= 0) {
275 return new WP_Error('no_balance_due', __('This booking is already fully paid.', 'yatra'), ['status' => 400]);
276 }
277
278 $trip = $this->tripRepository->findPublished((int) $booking->trip_id);
279
280 if (!$trip) {
281 return new WP_Error('trip_not_found', __('Trip associated with this booking is unavailable.', 'yatra'), ['status' => 400]);
282 }
283
284 $currency = $booking->currency ?? SettingsService::getCurrency();
285 $travelersCount = (int) ($booking->travelers_count ?? $booking->travelers ?? 1);
286 $travelersCount = max(1, $travelersCount);
287 $pricePerPerson = $travelersCount > 0 ? ((float) $booking->total_amount / $travelersCount) : (float) $trip->sale_price;
288
289 // Use dedicated remaining session (separate from booking session)
290 $remainingSessionData = [
291 'booking_id' => (int) $booking->id,
292 'booking_reference' => $booking->reference ?? '',
293 'trip_id' => (int) $trip->id,
294 'trip_title' => $trip->title,
295 'trip_slug' => $trip->slug,
296 'trip_price' => $pricePerPerson,
297 'trip_featured_image' => $trip->featured_image ?? '',
298 'currency' => $currency,
299 'travel_date' => $booking->travel_date,
300 'travelers' => $travelersCount,
301 'remaining_amount' => $remainingAmount,
302 'amount_paid' => (float) ($booking->amount_paid ?? 0),
303 'total_amount' => (float) ($booking->total_amount ?? 0),
304 'contact_first_name' => $booking->contact_first_name ?? '',
305 'contact_last_name' => $booking->contact_last_name ?? '',
306 'contact_email' => $booking->contact_email ?? $booking->customer_email ?? '',
307 'contact_phone' => $booking->contact_phone ?? $booking->customer_phone ?? '',
308 ];
309
310 // Clear any existing booking session to avoid confusion
311 yatra_clear_booking_session();
312 // Set the remaining payment session
313 yatra_set_remaining_session($remainingSessionData);
314
315 $checkoutUrl = yatra_get_checkout_url();
316 // Custom booking page is a normal WP page: pass trip slug so embedded booking UI can resolve the trip.
317 if (!empty($trip->slug) && SettingsService::useCustomBookingPage()) {
318 $checkoutUrl = add_query_arg('trip', rawurlencode((string) $trip->slug), $checkoutUrl);
319 }
320
321 return new WP_REST_Response([
322 'success' => true,
323 'data' => [
324 'checkout_url' => $checkoutUrl,
325 'booking_reference' => $booking->reference ?? '',
326 'return_url' => $this->getConfirmationUrl($booking->reference ?? ''),
327 ],
328 ]);
329 }
330
331 /**
332 * Get available gateways for checkout.
333 *
334 * For the *remaining-balance* checkout (when `yatra_has_remaining_session()` is
335 * true OR the request explicitly carries `?context=remaining`), offline gateways
336 * are filtered out — Pay Later / Bank Transfer don't actually collect money, so
337 * picking them to "settle a balance" leaves the booking still unpaid and the
338 * customer thinking they finished the flow. Filterable via
339 * `yatra_remaining_payment_allowed_gateways` if a site needs custom behaviour.
340 */
341 public function get_available_gateways(WP_REST_Request $request): WP_REST_Response
342 {
343 $gateways = $this->registry->getForCheckout();
344
345 $context = sanitize_key((string) ($request->get_param('context') ?? ''));
346 $isRemainingFlow = $context === 'remaining'
347 || (function_exists('yatra_has_remaining_session') && yatra_has_remaining_session());
348
349 if ($isRemainingFlow) {
350 $gateways = array_values(array_filter($gateways, static function ($gw) {
351 return empty($gw['is_offline']);
352 }));
353
354 /**
355 * Filter the gateway list shown in the remaining-balance checkout.
356 *
357 * Default: every offline gateway (Pay Later, Bank Transfer, etc.) is
358 * removed so the customer can only pick a real-money method.
359 *
360 * @param array $gateways Gateway entries (id, title, is_offline, …).
361 */
362 $gateways = apply_filters('yatra_remaining_payment_allowed_gateways', $gateways);
363 }
364
365 return new WP_REST_Response([
366 'gateways' => $gateways,
367 'currency' => get_option('yatra_currency', 'USD'),
368 'context' => $isRemainingFlow ? 'remaining' : 'initial',
369 ], 200);
370 }
371
372 /**
373 * Save gateway configuration
374 */
375 public function save_gateway_config(WP_REST_Request $request)
376 {
377 $gatewayId = $request->get_param('gateway_id');
378 $config = $request->get_json_params();
379
380 $gateway = $this->registry->get($gatewayId);
381 if (!$gateway) {
382 return new WP_Error('invalid_gateway', __('Gateway not found', 'yatra'), ['status' => 404]);
383 }
384
385 $saved = $gateway->saveConfig($config);
386
387 if ($saved) {
388 /**
389 * Fires after a payment gateway configuration is saved (telemetry / integrations).
390 *
391 * @param string $gatewayId Gateway id.
392 * @param array<string, mixed> $config Sanitized-bound request body.
393 */
394 do_action('yatra_payment_gateway_config_saved', (string) $gatewayId, is_array($config) ? $config : []);
395 }
396
397 return new WP_REST_Response([
398 'success' => $saved,
399 'message' => $saved ? __('Gateway configuration saved', 'yatra') : __('Failed to save configuration', 'yatra'),
400 ], $saved ? 200 : 500);
401 }
402
403 /**
404 * Create payment intent
405 */
406 public function create_payment_intent(WP_REST_Request $request)
407 {
408 $gatewayId = sanitize_text_field($request->get_param('gateway'));
409 $paymentData = [
410 'amount' => (float) $request->get_param('amount'),
411 'currency' => sanitize_text_field($request->get_param('currency') ?: get_option('yatra_currency', 'USD')),
412 'booking_id' => (int) $request->get_param('booking_id'),
413 'trip_id' => (int) $request->get_param('trip_id'),
414 'trip_date' => sanitize_text_field($request->get_param('trip_date') ?? ''),
415 'customer_email' => sanitize_email($request->get_param('customer_email')),
416 'customer_name' => sanitize_text_field($request->get_param('customer_name')),
417 'return_url' => esc_url_raw($request->get_param('return_url')),
418 ];
419
420 // Enrich payment data with booking context (reference, trip title, cancel URL).
421 // SECURITY: when a booking_id is supplied, the authoritative amount/currency must come
422 // from the database row, NOT from the client. Otherwise an attacker can pay $1 for a
423 // $1000 trip by tampering with the JSON body.
424 if ($paymentData['booking_id'] > 0) {
425 $booking = $this->bookingRepository->find($paymentData['booking_id']);
426 if (!$booking) {
427 return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
428 }
429
430 // If the booking is owned by a registered user, only that user (or an admin) may pay it.
431 // Guest bookings (user_id = 0) remain payable without auth — the booking session controls access.
432 $bookingUserId = (int) ($booking->user_id ?? 0);
433 if ($bookingUserId > 0) {
434 $currentUserId = (int) get_current_user_id();
435 if ($currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
436 return new WP_Error('forbidden', __('You do not have permission to pay for this booking.', 'yatra'), ['status' => 403]);
437 }
438 }
439
440 // Reject already-paid bookings to prevent duplicate intents.
441 if (isset($booking->payment_status) && $booking->payment_status === 'paid') {
442 return new WP_Error('already_paid', __('This booking is already fully paid.', 'yatra'), ['status' => 400]);
443 }
444
445 // Server-authoritative amount/currency. Use amount_due, falling back to total - paid for older rows.
446 $serverAmount = (float) ($booking->amount_due ?? ($booking->total_amount - $booking->amount_paid));
447 $serverCurrency = (string) ($booking->currency ?? get_option('yatra_currency', 'USD'));
448
449 if ($serverAmount <= 0) {
450 return new WP_Error('no_balance_due', __('This booking has no outstanding balance.', 'yatra'), ['status' => 400]);
451 }
452
453 // Tolerate sub-cent rounding drift only.
454 if (abs($paymentData['amount'] - $serverAmount) > 0.01) {
455 $this->log_amount_mismatch((int) $booking->id, $paymentData['amount'], $serverAmount);
456 }
457
458 // Always overwrite with server values regardless of what the client sent.
459 $paymentData['amount'] = $serverAmount;
460 $paymentData['currency'] = $serverCurrency;
461 $paymentData['reference'] = $booking->reference ?? '';
462 $paymentData['trip_title'] = $booking->trip_title ?? '';
463 if (empty($paymentData['trip_id'])) {
464 $paymentData['trip_id'] = (int) ($booking->trip_id ?? 0);
465 }
466 }
467
468 if (empty($paymentData['return_url'])) {
469 $reference = $paymentData['reference'] ?? (string) $paymentData['booking_id'];
470 $paymentData['return_url'] = add_query_arg('payment', 'success', $this->getConfirmationUrl($reference));
471 }
472
473 $cancelParam = esc_url_raw($request->get_param('cancel_url'));
474 $paymentData['cancel_url'] = $cancelParam ?: home_url('/book/?payment=cancelled&ref=' . ($paymentData['reference'] ?? $paymentData['booking_id']));
475
476 if ($paymentData['amount'] <= 0) {
477 return new WP_Error('invalid_amount', __('Invalid payment amount', 'yatra'), ['status' => 400]);
478 }
479
480 $result = $this->registry->processPayment($gatewayId, $paymentData);
481
482 if (!$result['success']) {
483 $errorMessage = $result['error'] ?? $result['message'] ?? __('Payment failed', 'yatra');
484 return new WP_Error('payment_error', $errorMessage, ['status' => 400]);
485 }
486
487 return new WP_REST_Response($result, 200);
488 }
489
490 private function getConfirmationUrl(string $reference): string
491 {
492 return yatra_get_booking_confirmation_url($reference);
493 }
494
495 /**
496 * Record an attempted payment-amount mismatch (client sent X, server expects Y).
497 * The transaction itself is forced to the server amount; this exists for fraud monitoring.
498 */
499 private function log_amount_mismatch(int $bookingId, float $clientAmount, float $serverAmount): void
500 {
501 if (defined('WP_DEBUG') && WP_DEBUG) {
502 error_log(sprintf(
503 '[Yatra] Payment amount mismatch for booking %d: client=%.4f server=%.4f',
504 $bookingId,
505 $clientAmount,
506 $serverAmount
507 ));
508 }
509
510 /**
511 * Fires when a client-supplied payment amount disagrees with the server-side booking amount.
512 * Useful for fraud-monitoring integrations.
513 */
514 do_action('yatra_payment_amount_mismatch', $bookingId, $clientAmount, $serverAmount);
515 }
516
517 /**
518 * Confirm payment
519 */
520 public function confirm_payment(WP_REST_Request $request)
521 {
522 $gatewayId = sanitize_text_field($request->get_param('gateway'));
523 $transactionId = sanitize_text_field($request->get_param('transaction_id'));
524 $bookingId = (int) $request->get_param('booking_id');
525 $saveCard = !empty($request->get_param('save_card'));
526
527 $gateway = $this->registry->get($gatewayId);
528 if (!$gateway) {
529 return new WP_Error('invalid_gateway', __('Gateway not found', 'yatra'), ['status' => 404]);
530 }
531
532 if ($bookingId <= 0 || $transactionId === '') {
533 return new WP_Error('invalid_request', __('booking_id and transaction_id are required.', 'yatra'), ['status' => 400]);
534 }
535
536 // Resolve the booking up front so we can enforce ownership BEFORE confirming a charge against it.
537 // Without this check, an anonymous attacker could mark booking B as paid by replaying a successful
538 // transaction_id that actually belongs to booking A.
539 $booking = $this->bookingRepository->find($bookingId);
540 if (!$booking) {
541 return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
542 }
543
544 $bookingUserId = (int) ($booking->user_id ?? 0);
545 if ($bookingUserId > 0) {
546 $currentUserId = (int) get_current_user_id();
547 if ($currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
548 return new WP_Error('forbidden', __('You do not have permission to confirm this payment.', 'yatra'), ['status' => 403]);
549 }
550 }
551
552 // Idempotency: if we have already recorded this transaction, return the cached verification result
553 // without re-applying the payment. Prevents duplicate ledger rows and double-confirmed bookings
554 // when the user reloads the confirmation page.
555 $existing = $this->paymentRepository->findByTransactionId($transactionId);
556 if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) {
557 return new WP_REST_Response([
558 'success' => true,
559 'status' => $existing->status ?? 'completed',
560 'amount' => (float) ($existing->amount ?? 0),
561 'currency' => $existing->currency ?? null,
562 'transaction_id' => $transactionId,
563 'idempotent' => true,
564 ], 200);
565 }
566
567 // If a payment with this transaction id is already attached to a DIFFERENT booking, refuse —
568 // someone is trying to reuse a stranger's transaction to pay their own booking.
569 if ($existing && (int) ($existing->booking_id ?? 0) !== $bookingId) {
570 return new WP_Error('transaction_mismatch', __('Transaction does not belong to this booking.', 'yatra'), ['status' => 409]);
571 }
572
573 $result = $gateway->verifyPayment($transactionId);
574
575 if ($result['success']) {
576 // Get customer and payment method from result
577 $customerId = $result['customer_id'] ?? null;
578 $paymentMethodId = $result['payment_method_id'] ?? $result['token_id'] ?? $result['vault_id'] ?? null;
579
580 $passForSchedule = (bool) apply_filters(
581 'yatra_pass_gateway_ids_for_scheduled_payments',
582 $saveCard,
583 $result,
584 $bookingId
585 );
586
587 $this->handle_successful_payment(
588 $bookingId,
589 $gatewayId,
590 $transactionId,
591 $result['amount'] ?? null,
592 $result['currency'] ?? null,
593 ($saveCard || $passForSchedule) ? $customerId : null,
594 ($saveCard || $passForSchedule) ? $paymentMethodId : null
595 );
596 }
597
598 return new WP_REST_Response($result, 200);
599 }
600
601 /**
602 * Handle webhook
603 */
604 public function handle_webhook(WP_REST_Request $request)
605 {
606 $gatewayId = $request->get_param('gateway');
607 $gateway = $this->registry->get($gatewayId);
608
609 if (!$gateway) {
610 return new WP_Error('invalid_gateway', __('Gateway not found', 'yatra'), ['status' => 404]);
611 }
612
613 $data = $request->get_json_params() ?: [];
614 $data['raw_body'] = $request->get_body();
615 $data['headers'] = $request->get_headers();
616 $data['post_data'] = $request->get_body_params(); // For form-encoded data (like PayPal IPN)
617
618 $result = $gateway->handleWebhook($data);
619
620 return new WP_REST_Response($result, 200);
621 }
622
623 /**
624 * Handle callback (for redirect-based payments)
625 */
626 public function handle_callback(WP_REST_Request $request): void
627 {
628 $gatewayId = $request->get_param('gateway');
629 $bookingId = (int) $request->get_param('booking_id');
630 $status = $request->get_param('status');
631
632 $gateway = $this->registry->get($gatewayId);
633
634 if (!$gateway) {
635 wp_redirect(home_url('/booking-failed/'));
636 exit;
637 }
638
639 // Get transaction ID from request (varies by gateway)
640 $transactionId = $request->get_param('refId')
641 ?? $request->get_param('pidx')
642 ?? $request->get_param('transaction_id')
643 ?? '';
644
645 if ($status === 'success' && !empty($transactionId)) {
646 $result = $gateway->verifyPayment($transactionId);
647
648 if ($result['success']) {
649 $this->handle_successful_payment($bookingId, $gatewayId, $transactionId);
650 wp_redirect(home_url('/booking-success/?booking_id=' . $bookingId));
651 exit;
652 }
653 }
654
655 wp_redirect(home_url('/booking-failed/'));
656 exit;
657 }
658
659 /**
660 * Get payment status
661 *
662 * Endpoint is public (`__return_true` permission) so guest checkouts can poll. Authorisation
663 * is enforced inline: registered-user bookings require the owning user (or an admin); guest
664 * bookings additionally require a matching short-lived booking_token transient so a stranger
665 * can't enumerate booking IDs to harvest payment metadata.
666 */
667 public function get_payment_status(WP_REST_Request $request)
668 {
669 $bookingId = (int) $request->get_param('booking_id');
670 $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? ''));
671
672 if ($bookingId <= 0) {
673 return new WP_Error('invalid_booking', __('Invalid booking ID.', 'yatra'), ['status' => 400]);
674 }
675
676 $payment = $this->paymentRepository->findLatestByBookingId($bookingId);
677
678 if (!$payment) {
679 return new WP_Error('payment_not_found', __('Payment not found', 'yatra'), ['status' => 404]);
680 }
681
682 $booking = $this->bookingRepository->find($bookingId);
683 $bookingUserId = $booking ? (int) ($booking->user_id ?? 0) : 0;
684 $currentUserId = (int) get_current_user_id();
685 $authorised = false;
686
687 if (current_user_can('manage_options')) {
688 $authorised = true;
689 } elseif ($bookingUserId > 0 && $currentUserId === $bookingUserId) {
690 $authorised = true;
691 } elseif ($bookingUserId === 0 && $bookingToken !== '') {
692 // Guest booking: require the booking-session transient to prove the requester is the
693 // browser that started this checkout.
694 $session = get_transient($bookingToken);
695 if (is_array($session) && (int) ($session['booking_id'] ?? 0) === $bookingId) {
696 $authorised = true;
697 }
698 }
699
700 if (!$authorised) {
701 if ($currentUserId > 0) {
702 return new WP_Error('forbidden', __('You do not have permission to view this payment.', 'yatra'), ['status' => 403]);
703 }
704 return new WP_Error('unauthorized', __('Authentication required.', 'yatra'), ['status' => 401]);
705 }
706
707 return new WP_REST_Response([
708 'status' => $payment->status,
709 'amount' => (float) $payment->amount,
710 'currency' => $payment->currency,
711 'gateway' => $payment->payment_gateway ?? $payment->gateway ?? '',
712 'transaction_id' => $payment->transaction_id,
713 'created_at' => $payment->created_at,
714 ], 200);
715 }
716
717 /**
718 * Record a completed charge against an existing booking (initial or remaining balance).
719 * Does not create bookings — only PaymentRepository::create + booking amount/status updates.
720 */
721 private function handle_successful_payment(
722 int $bookingId,
723 string $gateway,
724 string $transactionId,
725 ?float $amount = null,
726 ?string $currency = null,
727 ?string $customerId = null,
728 ?string $paymentMethodId = null
729 ): void {
730 if ($bookingId <= 0) {
731 return;
732 }
733
734 // Get booking details
735 $booking = $this->bookingRepository->find($bookingId);
736
737 if (!$booking) {
738 return;
739 }
740
741 $paid_amount = $amount ?? (float) $booking->amount_due;
742 $payment_currency = $currency ?? $booking->currency;
743
744 // Idempotency guard: skip if we have already recorded this gateway transaction for this booking.
745 // Prevents double-applied payments when both confirm_payment and the gateway's own return-handler
746 // (or a webhook) fire for the same charge.
747 if ($transactionId !== '') {
748 $existing = $this->paymentRepository->findByTransactionId($transactionId);
749 if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) {
750 return;
751 }
752 }
753
754 $payment_data = [
755 'booking_id' => $bookingId,
756 'gateway' => $gateway,
757 'transaction_id' => $transactionId,
758 'amount' => $paid_amount,
759 'currency' => $payment_currency,
760 'status' => 'completed',
761 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
762 ];
763
764 // Create or update payment record
765 $this->paymentRepository->create($payment_data);
766
767 // Calculate new amounts
768 $new_amount_paid = (float) $booking->amount_paid + $paid_amount;
769 $new_amount_due = max(0, (float) $booking->total_amount - $new_amount_paid);
770
771 // Determine payment status
772 $payment_status = 'paid';
773 if ($new_amount_due > 0) {
774 $payment_status = 'partial';
775 }
776
777 $previousBookingStatus = (string) ($booking->status ?? 'pending');
778
779 // Update booking
780 $this->bookingRepository->update($bookingId, [
781 'amount_paid' => $new_amount_paid,
782 'amount_due' => $new_amount_due,
783 'payment_status' => $payment_status,
784 'status' => 'confirmed',
785 ]);
786
787 \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus);
788
789 // Clear remaining payment session if this was a remaining payment
790 if (function_exists('yatra_has_remaining_session') && yatra_has_remaining_session()) {
791 yatra_clear_remaining_session();
792 }
793
794 do_action('yatra_payment_completed', $bookingId, $gateway, $transactionId, [
795 'amount' => $paid_amount,
796 'remaining' => $new_amount_due,
797 'customer_id' => $customerId,
798 'payment_method_id' => $paymentMethodId,
799 ]);
800 }
801
802 /**
803 * Download invoice PDF for a payment
804 */
805 public function download_invoice(WP_REST_Request $request)
806 {
807 $paymentId = (int) $request->get_param('payment_id');
808 $isPreview = $request->get_param('preview') === '1';
809 $isDownload = $request->get_param('download') === '1';
810 $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? ''));
811 $invoiceToken = sanitize_text_field((string) ($request->get_param('invoice_token') ?? ''));
812
813 if ($paymentId <= 0) {
814 return new WP_Error('invalid_payment', __('Invalid payment ID.', 'yatra'), ['status' => 400]);
815 }
816
817 // Get payment with booking details
818 $payment = $this->paymentRepository->findWithBooking($paymentId);
819
820 if (!$payment) {
821 return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]);
822 }
823
824 // Authorisation:
825 // 1. Administrators can always access (no further checks).
826 // 2. Logged-in owner of the booking can access.
827 // 3. Anyone with a valid signed `invoice_token` (HMAC) can access — used on the
828 // booking-confirmation page so guest checkouts and post-session views work.
829 // 4. Legacy guest path: `booking_token` (active checkout transient) — kept for BC.
830 $currentUserId = (int) get_current_user_id();
831 $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0);
832 $paymentBookingId = (int) ($payment->booking_id ?? 0);
833 $isAdmin = current_user_can('manage_options');
834 $authorised = false;
835
836 if ($isAdmin) {
837 $authorised = true;
838 } elseif ($currentUserId && $bookingUserId && $currentUserId === $bookingUserId) {
839 $authorised = true;
840 } elseif ($invoiceToken !== '' && self::verifyInvoiceToken($invoiceToken, (int) $payment->id, $paymentBookingId)) {
841 $authorised = true;
842 } elseif ($bookingToken !== '') {
843 $guestEnabled = (bool) SettingsService::get('allow_guest_checkout', true);
844 if ($guestEnabled) {
845 $session = get_transient($bookingToken);
846 if (is_array($session)) {
847 $sessionBookingId = (int) ($session['booking_id'] ?? 0);
848 if ($sessionBookingId > 0 && $paymentBookingId > 0 && $sessionBookingId === $paymentBookingId) {
849 $authorised = true;
850 }
851 }
852 }
853 }
854
855 if (!$authorised) {
856 if ($currentUserId) {
857 return new WP_Error('forbidden', __('You do not have permission to access this invoice.', 'yatra'), ['status' => 403]);
858 }
859 return new WP_Error('unauthorized', __('You must be logged in to download invoices.', 'yatra'), ['status' => 401]);
860 }
861
862 // Get trip details if available
863 $trip = null;
864 if (!empty($payment->trip_id)) {
865 $trip = $this->tripRepository->find((int) $payment->trip_id);
866 }
867
868 // Get company settings
869 $companyName = SettingsService::get('company_name', get_bloginfo('name'));
870 $companyAddress = SettingsService::get('company_address', '');
871 $companyEmail = SettingsService::get('company_email', get_option('admin_email'));
872 $companyPhone = SettingsService::get('company_phone', '');
873 $currency = SettingsService::getCurrency();
874 $currencySymbol = FormatHelper::getCurrencySymbol($currency);
875
876 // Format dates
877 $paymentDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
878 $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
879
880
881 $bookingRef = (string) ($payment->booking_reference ?? $payment->booking_number ?? $payment->reference ?? (string) $paymentId);
882 $filename = 'Invoice #' . $bookingRef . '.pdf';
883
884
885 $pdfService = new PdfService();
886 if (!$pdfService->isAvailable()) {
887 return new WP_Error(
888 'pdf_engine_missing',
889 __('Invoice PDF generator is not installed. Please run composer install to install dompdf/dompdf.', 'yatra'),
890 ['status' => 500]
891 );
892 }
893
894 // Get tax breakdown for invoice
895 $tax_breakdown = [];
896 $tax_amount = 0;
897 $subtotal = (float) ($payment->booking_total_amount ?? $payment->amount ?? 0);
898
899 if (!empty($payment->tax_details)) {
900 $taxes = json_decode($payment->tax_details, true) ?: [];
901 foreach ($taxes as $tax) {
902 $tax_amount += (float) ($tax['amount'] ?? 0);
903 $tax_breakdown[] = [
904 'name' => $tax['name'] ?? 'Tax',
905 'rate' => $tax['rate'] ?? 0,
906 'amount' => $tax['amount'] ?? 0
907 ];
908 }
909 // Adjust subtotal for tax-exclusive pricing
910 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
911 $subtotal = (float) ($payment->subtotal ?? $subtotal);
912 }
913 } elseif (!empty($payment->tax_amount) && $payment->tax_amount > 0) {
914 // Single tax fallback
915 $tax_amount = (float) $payment->tax_amount;
916 $tax_breakdown[] = [
917 'name' => __('Tax', 'yatra'),
918 'rate' => (float) ($payment->tax_rate ?? 0),
919 'amount' => $tax_amount
920 ];
921 // Adjust subtotal for tax-exclusive pricing
922 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
923 $subtotal = (float) ($payment->subtotal ?? $subtotal);
924 } else {
925 $subtotal = (float) ($payment->subtotal ?? ($subtotal - $tax_amount));
926 }
927 }
928
929 $templateData = [
930 'company_name' => $companyName,
931 'company_address' => $companyAddress,
932 'company_email' => $companyEmail,
933 'company_phone' => $companyPhone,
934 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
935 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
936 'payment_ref' => $payment->reference ?? '',
937 'payment_date' => $paymentDate,
938 'payment_status' => ucfirst($payment->status ?? 'paid'),
939 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['paid', 'completed', 'success'], true) ? 'paid' : 'pending',
940 'trip_title' => $trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra'),
941 'payment_method' => ucfirst($payment->gateway ?? $payment->payment_method ?? 'Online'),
942 'booking_ref' => $payment->booking_reference ?? $payment->booking_number ?? '',
943 'travel_date' => $travelDate,
944 'currency_symbol' => $currencySymbol,
945 'amount' => number_format((float) ($payment->amount ?? 0), 2),
946 'booking_total' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
947 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
948 'amount_due' => number_format((float) ($payment->booking_amount_due ?? 0), 2),
949 'tax_breakdown' => $tax_breakdown,
950 'tax_amount' => number_format($tax_amount, 2),
951 'subtotal' => number_format($subtotal, 2),
952 ];
953
954 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/invoice.php', $templateData, [
955 'paper' => 'A4',
956 'orientation' => 'portrait',
957 'default_font' => 'DejaVu Sans',
958 ]);
959
960 if ($isPreview) {
961 // For preview, return PDF as inline display
962 return new WP_REST_Response([
963 'success' => true,
964 'pdf_data' => base64_encode($pdfBinary),
965 'filename' => $filename,
966 ]);
967 } else {
968 // For download, output PDF as download
969 $pdfService->outputPdfDownload($pdfBinary, $filename);
970 exit;
971 }
972 }
973
974 /**
975 * Download travel voucher PDF for a booking
976 */
977 public function download_voucher(WP_REST_Request $request)
978 {
979 $paymentId = (int) $request->get_param('payment_id');
980 $isPreview = $request->get_param('preview') === '1';
981 $isDownload = $request->get_param('download') === '1';
982
983 if ($paymentId <= 0) {
984 return new WP_Error('invalid_payment', __('Invalid payment ID.', 'yatra'), ['status' => 400]);
985 }
986
987 // Get payment with booking details
988 $payment = $this->paymentRepository->findWithBooking($paymentId);
989
990 if (!$payment) {
991 return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]);
992 }
993
994 // Verify user is logged in and owns this payment (or is admin)
995 $currentUserId = get_current_user_id();
996 $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0);
997
998 // Must be logged in
999 if (!$currentUserId) {
1000 return new WP_Error('unauthorized', __('You must be logged in to download vouchers.', 'yatra'), ['status' => 401]);
1001 }
1002
1003 // Must own the booking or be admin
1004 if ($bookingUserId && $currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
1005 return new WP_Error('forbidden', __('You do not have permission to access this voucher.', 'yatra'), ['status' => 403]);
1006 }
1007
1008 // Get trip details if available
1009 $trip = null;
1010 if (!empty($payment->trip_id)) {
1011 $trip = $this->tripRepository->find((int) $payment->trip_id);
1012 }
1013
1014 // Get company settings
1015 $companyName = SettingsService::get('company_name', get_bloginfo('name'));
1016 $companyAddress = SettingsService::get('company_address', '');
1017 $companyEmail = SettingsService::get('company_email', get_option('admin_email'));
1018 $companyPhone = SettingsService::get('company_phone', '');
1019 $currency = SettingsService::getCurrency();
1020 $currencySymbol = FormatHelper::getCurrencySymbol($currency);
1021
1022 // Format dates
1023 $bookingDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
1024 $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
1025
1026 // Calculate return date if duration is available (duration_days column).
1027 $returnDate = '';
1028 $durationDaysForReturn = (int) ($payment->trip_duration_days ?? ($trip->duration_days ?? 0));
1029 if (!empty($payment->travel_date) && $durationDaysForReturn > 0) {
1030 $returnTimestamp = strtotime($payment->travel_date . ' +' . $durationDaysForReturn . ' days');
1031 $returnDate = date_i18n(get_option('date_format'), $returnTimestamp);
1032 }
1033
1034 $bookingRef = (string) ($payment->booking_reference ?? $payment->booking_number ?? $payment->reference ?? (string) $paymentId);
1035 $filename = 'Travel Voucher #' . $bookingRef . '.pdf';
1036
1037 $pdfService = new PdfService();
1038 if (!$pdfService->isAvailable()) {
1039 return new WP_Error(
1040 'pdf_engine_missing',
1041 __('Voucher PDF generator is not installed. Please run composer install to install dompdf/dompdf.', 'yatra'),
1042 ['status' => 500]
1043 );
1044 }
1045
1046 $templateData = [
1047 'company_name' => $companyName,
1048 'company_address' => $companyAddress,
1049 'company_email' => $companyEmail,
1050 'company_phone' => $companyPhone,
1051 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
1052 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
1053 'booking_ref' => $bookingRef,
1054 'booking_date' => $bookingDate,
1055 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
1056 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
1057 (in_array(strtolower((string) ($payment->status ?? '')), ['cancelled'], true) ? 'cancelled' : 'pending'),
1058 'trip_title' => $trip ? ($trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra')) : ($payment->trip_title ?? __('Trip Booking', 'yatra')),
1059 // Trip duration: prefer the duration columns joined onto the payment
1060 // row (always present, even if the trip was later soft-deleted),
1061 // falling back to the loaded trip. There is no `duration` column.
1062 'trip_duration' => yatra_format_duration(
1063 (int) ($payment->trip_duration_days ?? ($trip->duration_days ?? 0)),
1064 isset($payment->trip_duration_nights)
1065 ? (int) $payment->trip_duration_nights
1066 : (isset($trip->duration_nights) ? (int) $trip->duration_nights : null)
1067 ),
1068 'trip_difficulty' => $trip ? ($trip->difficulty_name ?? '') : '',
1069 'departure_location' => $trip ? ($trip->departure_location ?? '') : '',
1070 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
1071 'travel_date' => $travelDate,
1072 'return_date' => $returnDate,
1073 'currency_symbol' => $currencySymbol,
1074 'total_amount' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2),
1075 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2),
1076 'amount_due' => number_format((float) ($payment->booking_amount_due ?? 0), 2),
1077 'traveler_count' => (int) ($payment->traveler_count ?? 1),
1078 ];
1079
1080 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/voucher.php', $templateData, [
1081 'paper' => 'A4',
1082 'orientation' => 'portrait',
1083 'default_font' => 'DejaVu Sans',
1084 ]);
1085
1086 if ($isPreview) {
1087 // For preview, return PDF as inline display
1088 return new WP_REST_Response([
1089 'success' => true,
1090 'pdf_data' => base64_encode($pdfBinary),
1091 'filename' => $filename,
1092 ]);
1093 } else {
1094 // For download, output PDF as download
1095 $pdfService->outputPdfDownload($pdfBinary, $filename);
1096 exit;
1097 }
1098 }
1099
1100 /**
1101 * GET /payments/{payment_id}/itinerary - Download travel itinerary for a payment
1102 */
1103 public function download_itinerary(WP_REST_Request $request)
1104 {
1105 $paymentId = (int) $request->get_param('payment_id');
1106 $isPreview = $request->get_param('preview') === '1';
1107 $isDownload = $request->get_param('download') === '1';
1108
1109 if ($paymentId <= 0) {
1110 return new WP_Error('invalid_payment', __('Invalid payment ID.', 'yatra'), ['status' => 400]);
1111 }
1112
1113 // Get payment with booking details
1114 $payment = $this->paymentRepository->findWithBooking($paymentId);
1115
1116 if (!$payment) {
1117 return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]);
1118 }
1119
1120 // Verify user is logged in and owns this payment (or is admin)
1121 $currentUserId = get_current_user_id();
1122 $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0);
1123
1124 // Must be logged in
1125 if (!$currentUserId) {
1126 return new WP_Error('unauthorized', __('You must be logged in to download itineraries.', 'yatra'), ['status' => 401]);
1127 }
1128
1129 // Must own the booking or be admin
1130 if ($bookingUserId && $currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
1131 return new WP_Error('forbidden', __('You do not have permission to access this itinerary.', 'yatra'), ['status' => 403]);
1132 }
1133
1134 // Delegate all the template-data composition + PDF rendering to
1135 // the shared ItineraryPdfBuilder so the booking-side path
1136 // (BookingsController::renderItineraryFromBookingData) and this
1137 // payment-side path produce IDENTICAL PDFs from the same input.
1138 $builder = new \Yatra\Services\ItineraryPdfBuilder();
1139 if (!$builder->pdfService()->isAvailable()) {
1140 return new WP_Error(
1141 'pdf_engine_missing',
1142 __('Itinerary PDF generator is not installed. Please run composer install to install dompdf/dompdf.', 'yatra'),
1143 ['status' => 500]
1144 );
1145 }
1146
1147 $bookingRef = !empty($payment->booking_id)
1148 ? 'YTR-' . strtoupper(str_pad((string) $payment->booking_id, 8, '0', STR_PAD_LEFT))
1149 : 'PENDING';
1150 $filename = 'Travel-Itinerary-' . $bookingRef . '.pdf';
1151
1152 $pdfBinary = $builder->buildFromPaymentRecord($payment);
1153
1154 if ($isPreview) {
1155 return new WP_REST_Response([
1156 'success' => true,
1157 'pdf_data' => base64_encode($pdfBinary),
1158 'filename' => $filename,
1159 ]);
1160 }
1161
1162 $builder->pdfService()->outputPdfDownload($pdfBinary, $filename);
1163 exit;
1164 }
1165
1166 /**
1167 * Default invoice-token TTL — 1 year. Customers download invoices
1168 * for tax/expense reports months later, so a short TTL would hurt
1169 * legitimate use. The TTL is still meaningful as defense-in-depth:
1170 * a leaked link (forwarded email, posted in a help-desk ticket,
1171 * cached by a public mail relay) eventually expires.
1172 *
1173 * Filterable via `yatra_invoice_token_ttl_seconds` so operators
1174 * can tighten or loosen on a per-site basis.
1175 */
1176 private const INVOICE_TOKEN_DEFAULT_TTL = 365 * 86400;
1177
1178 /**
1179 * Issue a stateless, signed token that grants access to a single
1180 * payment's invoice. v2 format embeds an issued-at timestamp so
1181 * tokens have a defined expiry window — older v1 tokens (no
1182 * expiry component) are still honored by verifyInvoiceToken() so
1183 * pre-existing confirmation emails don't break.
1184 *
1185 * v2 format: `v2.<iat>.<hmac>` where hmac signs `paymentId|bookingId|iat`.
1186 * v1 format: bare `<hmac>` over `paymentId|bookingId` (legacy).
1187 *
1188 * The token is bound to the payment id + booking id and signed
1189 * with the WP auth salt, so it cannot be forged without the site
1190 * secret. It is safe to embed in the confirmation page link so
1191 * guests (or users who logged out after checkout) can still
1192 * download their invoice without a session.
1193 */
1194 public static function issueInvoiceToken(int $paymentId, int $bookingId): string
1195 {
1196 if ($paymentId <= 0 || $bookingId <= 0) {
1197 return '';
1198 }
1199 $iat = time();
1200 $hmac = hash_hmac(
1201 'sha256',
1202 $paymentId . '|' . $bookingId . '|' . $iat,
1203 wp_salt('auth') . '|yatra_invoice'
1204 );
1205 return 'v2.' . $iat . '.' . $hmac;
1206 }
1207
1208 /**
1209 * Verify a token previously issued by self::issueInvoiceToken().
1210 *
1211 * Accepts both formats:
1212 * - v2 (`v2.<iat>.<hmac>`): validates HMAC + checks token age
1213 * against the configured TTL.
1214 * - v1 (bare hmac, no expiry): legacy tokens already in the
1215 * wild via prior confirmation emails. We accept them
1216 * indefinitely — those URLs were already issued and revoking
1217 * them now would break existing customer bookmarks.
1218 */
1219 public static function verifyInvoiceToken(string $token, int $paymentId, int $bookingId): bool
1220 {
1221 if ($token === '' || $paymentId <= 0 || $bookingId <= 0) {
1222 return false;
1223 }
1224
1225 // v2 path — token starts with the version prefix.
1226 if (strncmp($token, 'v2.', 3) === 0) {
1227 $parts = explode('.', $token);
1228 if (\count($parts) !== 3) return false;
1229 $iatStr = $parts[1];
1230 $providedHmac = $parts[2];
1231 if (!ctype_digit($iatStr)) return false;
1232 $iat = (int) $iatStr;
1233
1234 $expectedHmac = hash_hmac(
1235 'sha256',
1236 $paymentId . '|' . $bookingId . '|' . $iat,
1237 wp_salt('auth') . '|yatra_invoice'
1238 );
1239 if (!hash_equals($expectedHmac, $providedHmac)) {
1240 return false;
1241 }
1242
1243 $ttl = (int) apply_filters(
1244 'yatra_invoice_token_ttl_seconds',
1245 self::INVOICE_TOKEN_DEFAULT_TTL
1246 );
1247 if ($ttl > 0 && (time() - $iat) > $ttl) {
1248 return false;
1249 }
1250 return true;
1251 }
1252
1253 // v1 legacy path — bare HMAC over (paymentId|bookingId).
1254 // Kept for confirmation emails already sent before the v2
1255 // upgrade landed. New code paths always issue v2.
1256 $expectedLegacy = hash_hmac(
1257 'sha256',
1258 $paymentId . '|' . $bookingId,
1259 wp_salt('auth') . '|yatra_invoice'
1260 );
1261 return hash_equals($expectedLegacy, $token);
1262 }
1263 }
1264