PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / app / Controllers / PaymentGatewayController.php

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

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