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

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

1,429 lines 63.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Controllers;
6
7 use WP_REST_Request;
8 use WP_REST_Response;
9 use WP_Error;
10 use Yatra\PaymentGateways\PaymentGatewayRegistry;
11 use Yatra\Repositories\BookingRepository;
12 use Yatra\Repositories\PaymentRepository;
13 use Yatra\Repositories\TripRepository;
14 use Yatra\Helpers\FormatHelper;
15 use Yatra\Services\PdfService;
16 use Yatra\Services\SettingsService;
17
18 /**
19 * Payment Gateway REST API Controller
20 *
21 * Handles payment gateway operations and payment processing
22 */
23 class PaymentGatewayController extends BaseController
24 {
25 private PaymentGatewayRegistry $registry;
26 private BookingRepository $bookingRepository;
27 private PaymentRepository $paymentRepository;
28 private TripRepository $tripRepository;
29
30 public function __construct()
31 {
32 $this->registry = PaymentGatewayRegistry::getInstance();
33 $this->bookingRepository = new BookingRepository();
34 $this->paymentRepository = new PaymentRepository();
35 $this->tripRepository = new TripRepository();
36 }
37
38 public function register_routes(): void
39 {
40 $namespace = 'yatra/v1';
41 $base = 'payment';
42
43 // Get gateway definitions for admin settings
44 register_rest_route($namespace, '/' . $base . '/gateways/definitions', [
45 [
46 'methods' => \WP_REST_Server::READABLE,
47 'callback' => [$this, 'get_gateway_definitions'],
48 'permission_callback' => [$this, 'check_admin_permission'],
49 ],
50 ]);
51
52 // Get available gateways for checkout
53 register_rest_route($namespace, '/' . $base . '/gateways', [
54 [
55 'methods' => \WP_REST_Server::READABLE,
56 'callback' => [$this, 'get_available_gateways'],
57 'permission_callback' => '__return_true',
58 ],
59 ]);
60
61 // Save gateway config
62 register_rest_route($namespace, '/' . $base . '/gateways/(?P<gateway_id>[a-z_]+)/config', [
63 [
64 'methods' => \WP_REST_Server::CREATABLE,
65 'callback' => [$this, 'save_gateway_config'],
66 'permission_callback' => [$this, 'check_admin_permission'],
67 ],
68 ]);
69
70 // Create payment intent
71 register_rest_route($namespace, '/' . $base . '/create-intent', [
72 [
73 'methods' => \WP_REST_Server::CREATABLE,
74 'callback' => [$this, 'create_payment_intent'],
75 'permission_callback' => '__return_true',
76 ],
77 ]);
78
79 // Confirm payment
80 register_rest_route($namespace, '/' . $base . '/confirm', [
81 [
82 'methods' => \WP_REST_Server::CREATABLE,
83 'callback' => [$this, 'confirm_payment'],
84 'permission_callback' => '__return_true',
85 ],
86 ]);
87
88 // Webhook handlers
89 register_rest_route($namespace, '/' . $base . '/webhook/(?P<gateway>[a-z_]+)', [
90 [
91 'methods' => \WP_REST_Server::CREATABLE,
92 'callback' => [$this, 'handle_webhook'],
93 'permission_callback' => '__return_true',
94 ],
95 ]);
96
97 // Payment callback (for redirect-based payments)
98 register_rest_route($namespace, '/' . $base . '/callback/(?P<gateway>[a-z_]+)', [
99 [
100 'methods' => \WP_REST_Server::READABLE,
101 'callback' => [$this, 'handle_callback'],
102 'permission_callback' => '__return_true',
103 ],
104 ]);
105
106 // Get payment status
107 register_rest_route($namespace, '/' . $base . '/status/(?P<booking_id>[\d]+)', [
108 [
109 'methods' => \WP_REST_Server::READABLE,
110 'callback' => [$this, 'get_payment_status'],
111 'permission_callback' => '__return_true',
112 ],
113 ]);
114
115 register_rest_route($namespace, '/' . $base . '/remaining', [
116 [
117 'methods' => \WP_REST_Server::CREATABLE,
118 'callback' => [$this, 'create_remaining_balance_intent'],
119 'permission_callback' => [$this, 'check_customer_permission'],
120 ],
121 ]);
122
123 register_rest_route($namespace, '/' . $base . '/remaining/session', [
124 [
125 'methods' => \WP_REST_Server::CREATABLE,
126 'callback' => [$this, 'start_remaining_payment_session'],
127 'permission_callback' => [$this, 'check_customer_permission'],
128 ],
129 ]);
130
131 // Download invoice for a payment
132 register_rest_route($namespace, '/' . $base . '/(?P<payment_id>[\d]+)/invoice', [
133 [
134 'methods' => \WP_REST_Server::READABLE,
135 'callback' => [$this, 'download_invoice'],
136 'permission_callback' => '__return_true', // Auth checked inside callback
137 ],
138 ]);
139
140 // Download travel voucher for a payment
141 register_rest_route($namespace, '/' . $base . '/(?P<payment_id>[\d]+)/voucher', [
142 [
143 'methods' => \WP_REST_Server::READABLE,
144 'callback' => [$this, 'download_voucher'],
145 'permission_callback' => '__return_true', // Auth checked inside callback
146 ],
147 ]);
148
149 // 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 $paymentData['cancel_url'] = $cancelParam ?: home_url('/book/?payment=cancelled&ref=' . ($paymentData['reference'] ?? $paymentData['booking_id']));
486
487 if ($paymentData['amount'] <= 0) {
488 return new WP_Error('invalid_amount', __('Invalid payment amount', 'yatra'), ['status' => 400]);
489 }
490
491 $result = $this->registry->processPayment($gatewayId, $paymentData);
492
493 if (!$result['success']) {
494 $errorMessage = $result['error'] ?? $result['message'] ?? __('Payment failed', 'yatra');
495 return new WP_Error('payment_error', $errorMessage, ['status' => 400]);
496 }
497
498 return new WP_REST_Response($result, 200);
499 }
500
501 private function getConfirmationUrl(string $reference): string
502 {
503 return yatra_get_booking_confirmation_url($reference);
504 }
505
506 /**
507 * Record an attempted payment-amount mismatch (client sent X, server expects Y).
508 * The transaction itself is forced to the server amount; this exists for fraud monitoring.
509 */
510 private function log_amount_mismatch(int $bookingId, float $clientAmount, float $serverAmount): void
511 {
512 if (defined('WP_DEBUG') && WP_DEBUG) {
513 error_log(sprintf(
514 '[Yatra] Payment amount mismatch for booking %d: client=%.4f server=%.4f',
515 $bookingId,
516 $clientAmount,
517 $serverAmount
518 ));
519 }
520
521 /**
522 * Fires when a client-supplied payment amount disagrees with the server-side booking amount.
523 * Useful for fraud-monitoring integrations.
524 */
525 do_action('yatra_payment_amount_mismatch', $bookingId, $clientAmount, $serverAmount);
526 }
527
528 /**
529 * Confirm payment
530 */
531 public function confirm_payment(WP_REST_Request $request)
532 {
533 $gatewayId = sanitize_text_field($request->get_param('gateway'));
534 $transactionId = sanitize_text_field($request->get_param('transaction_id'));
535 $bookingId = (int) $request->get_param('booking_id');
536 $saveCard = !empty($request->get_param('save_card'));
537
538 $gateway = $this->registry->get($gatewayId);
539 if (!$gateway) {
540 return new WP_Error('invalid_gateway', __('Gateway not found', 'yatra'), ['status' => 404]);
541 }
542
543 if ($bookingId <= 0 || $transactionId === '') {
544 return new WP_Error('invalid_request', __('booking_id and transaction_id are required.', 'yatra'), ['status' => 400]);
545 }
546
547 // Resolve the booking up front so we can enforce ownership BEFORE confirming a charge against it.
548 // Without this check, an anonymous attacker could mark booking B as paid by replaying a successful
549 // transaction_id that actually belongs to booking A.
550 $booking = $this->bookingRepository->find($bookingId);
551 if (!$booking) {
552 return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
553 }
554
555 $bookingUserId = (int) ($booking->user_id ?? 0);
556 if ($bookingUserId > 0) {
557 $currentUserId = (int) get_current_user_id();
558 if ($currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
559 return new WP_Error('forbidden', __('You do not have permission to confirm this payment.', 'yatra'), ['status' => 403]);
560 }
561 }
562
563 // Idempotency: if we have already recorded this transaction, return the cached verification result
564 // without re-applying the payment. Prevents duplicate ledger rows and double-confirmed bookings
565 // when the user reloads the confirmation page.
566 $existing = $this->paymentRepository->findByTransactionId($transactionId);
567 if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) {
568 return new WP_REST_Response([
569 'success' => true,
570 'status' => $existing->status ?? 'completed',
571 'amount' => (float) ($existing->amount ?? 0),
572 'currency' => $existing->currency ?? null,
573 'transaction_id' => $transactionId,
574 'idempotent' => true,
575 ], 200);
576 }
577
578 // If a payment with this transaction id is already attached to a DIFFERENT booking, refuse —
579 // someone is trying to reuse a stranger's transaction to pay their own booking.
580 if ($existing && (int) ($existing->booking_id ?? 0) !== $bookingId) {
581 return new WP_Error('transaction_mismatch', __('Transaction does not belong to this booking.', 'yatra'), ['status' => 409]);
582 }
583
584 $result = $gateway->verifyPayment($transactionId);
585
586 if ($result['success']) {
587 // Get customer and payment method from result
588 $customerId = $result['customer_id'] ?? null;
589 $paymentMethodId = $result['payment_method_id'] ?? $result['token_id'] ?? $result['vault_id'] ?? null;
590
591 $passForSchedule = (bool) apply_filters(
592 'yatra_pass_gateway_ids_for_scheduled_payments',
593 $saveCard,
594 $result,
595 $bookingId
596 );
597
598 $this->handle_successful_payment(
599 $bookingId,
600 $gatewayId,
601 $transactionId,
602 $result['amount'] ?? null,
603 $result['currency'] ?? null,
604 ($saveCard || $passForSchedule) ? $customerId : null,
605 ($saveCard || $passForSchedule) ? $paymentMethodId : null
606 );
607 }
608
609 return new WP_REST_Response($result, 200);
610 }
611
612 /**
613 * Handle webhook
614 */
615 public function handle_webhook(WP_REST_Request $request)
616 {
617 $gatewayId = $request->get_param('gateway');
618 $gateway = $this->registry->get($gatewayId);
619
620 if (!$gateway) {
621 return new WP_Error('invalid_gateway', __('Gateway not found', 'yatra'), ['status' => 404]);
622 }
623
624 $data = $request->get_json_params() ?: [];
625 $data['raw_body'] = $request->get_body();
626 $data['headers'] = $request->get_headers();
627 $data['post_data'] = $request->get_body_params(); // For form-encoded data (like PayPal IPN)
628
629 $result = $gateway->handleWebhook($data);
630
631 return new WP_REST_Response($result, 200);
632 }
633
634 /**
635 * Handle callback (for redirect-based payments)
636 */
637 public function handle_callback(WP_REST_Request $request): void
638 {
639 $gatewayId = $request->get_param('gateway');
640 $bookingId = (int) $request->get_param('booking_id');
641 $status = $request->get_param('status');
642
643 $gateway = $this->registry->get($gatewayId);
644
645 if (!$gateway) {
646 wp_redirect(home_url('/booking-failed/'));
647 exit;
648 }
649
650 // Get transaction ID from request (varies by gateway)
651 $transactionId = $request->get_param('refId')
652 ?? $request->get_param('pidx')
653 ?? $request->get_param('transaction_id')
654 ?? '';
655
656 if ($status === 'success' && !empty($transactionId)) {
657 $result = $gateway->verifyPayment($transactionId);
658
659 if ($result['success']) {
660 $this->handle_successful_payment($bookingId, $gatewayId, $transactionId);
661 wp_redirect(home_url('/booking-success/?booking_id=' . $bookingId));
662 exit;
663 }
664 }
665
666 wp_redirect(home_url('/booking-failed/'));
667 exit;
668 }
669
670 /**
671 * Get payment status
672 *
673 * Endpoint is public (`__return_true` permission) so guest checkouts can poll. Authorisation
674 * is enforced inline: registered-user bookings require the owning user (or an admin); guest
675 * bookings additionally require a matching short-lived booking_token transient so a stranger
676 * can't enumerate booking IDs to harvest payment metadata.
677 */
678 public function get_payment_status(WP_REST_Request $request)
679 {
680 $bookingId = (int) $request->get_param('booking_id');
681 $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? ''));
682
683 if ($bookingId <= 0) {
684 return new WP_Error('invalid_booking', __('Invalid booking ID.', 'yatra'), ['status' => 400]);
685 }
686
687 $payment = $this->paymentRepository->findLatestByBookingId($bookingId);
688
689 if (!$payment) {
690 return new WP_Error('payment_not_found', __('Payment not found', 'yatra'), ['status' => 404]);
691 }
692
693 $booking = $this->bookingRepository->find($bookingId);
694 $bookingUserId = $booking ? (int) ($booking->user_id ?? 0) : 0;
695 $currentUserId = (int) get_current_user_id();
696 $authorised = false;
697
698 if (current_user_can('manage_options')) {
699 $authorised = true;
700 } elseif ($bookingUserId > 0 && $currentUserId === $bookingUserId) {
701 $authorised = true;
702 } elseif ($bookingUserId === 0 && $bookingToken !== '') {
703 // Guest booking: require the booking-session transient to prove the requester is the
704 // browser that started this checkout.
705 $session = get_transient($bookingToken);
706 if (is_array($session) && (int) ($session['booking_id'] ?? 0) === $bookingId) {
707 $authorised = true;
708 }
709 }
710
711 if (!$authorised) {
712 if ($currentUserId > 0) {
713 return new WP_Error('forbidden', __('You do not have permission to view this payment.', 'yatra'), ['status' => 403]);
714 }
715 return new WP_Error('unauthorized', __('Authentication required.', 'yatra'), ['status' => 401]);
716 }
717
718 return new WP_REST_Response([
719 'status' => $payment->status,
720 'amount' => (float) $payment->amount,
721 'currency' => $payment->currency,
722 'gateway' => $payment->payment_gateway ?? $payment->gateway ?? '',
723 'transaction_id' => $payment->transaction_id,
724 'created_at' => $payment->created_at,
725 ], 200);
726 }
727
728 /**
729 * Record a completed charge against an existing booking (initial or remaining balance).
730 * Does not create bookings — only PaymentRepository::create + booking amount/status updates.
731 */
732 private function handle_successful_payment(
733 int $bookingId,
734 string $gateway,
735 string $transactionId,
736 ?float $amount = null,
737 ?string $currency = null,
738 ?string $customerId = null,
739 ?string $paymentMethodId = null
740 ): void {
741 if ($bookingId <= 0) {
742 return;
743 }
744
745 // Get booking details
746 $booking = $this->bookingRepository->find($bookingId);
747
748 if (!$booking) {
749 return;
750 }
751
752 $paid_amount = $amount ?? (float) $booking->amount_due;
753 $payment_currency = $currency ?? $booking->currency;
754
755 // Idempotency guard: skip if we have already recorded this gateway transaction for this booking.
756 // Prevents double-applied payments when both confirm_payment and the gateway's own return-handler
757 // (or a webhook) fire for the same charge.
758 if ($transactionId !== '') {
759 $existing = $this->paymentRepository->findByTransactionId($transactionId);
760 if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) {
761 return;
762 }
763 }
764
765 $payment_data = [
766 'booking_id' => $bookingId,
767 'gateway' => $gateway,
768 'transaction_id' => $transactionId,
769 'amount' => $paid_amount,
770 'currency' => $payment_currency,
771 'status' => 'completed',
772 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
773 ];
774
775 // Create or update payment record
776 $this->paymentRepository->create($payment_data);
777
778 // Calculate new amounts
779 $new_amount_paid = (float) $booking->amount_paid + $paid_amount;
780 $new_amount_due = max(0, (float) $booking->total_amount - $new_amount_paid);
781
782 // Determine payment status
783 $payment_status = 'paid';
784 if ($new_amount_due > 0) {
785 $payment_status = 'partial';
786 }
787
788 $previousBookingStatus = (string) ($booking->status ?? 'pending');
789
790 // Only auto-confirm when the operator allows it (or the booking is now
791 // fully paid). A deposit / partial payment leaves the booking pending
792 // when "Auto-Confirm Bookings" is off, for the operator to confirm.
793 $should_confirm = \yatra_should_confirm_booking_on_payment($new_amount_due <= 0, $bookingId);
794
795 // Update booking
796 $booking_update = [
797 'amount_paid' => $new_amount_paid,
798 'amount_due' => $new_amount_due,
799 'payment_status' => $payment_status,
800 ];
801 if ($should_confirm) {
802 $booking_update['status'] = 'confirmed';
803 }
804 $this->bookingRepository->update($bookingId, $booking_update);
805
806 if ($should_confirm) {
807 \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus);
808 }
809
810 // Clear remaining payment session if this was a remaining payment
811 if (function_exists('yatra_has_remaining_session') && yatra_has_remaining_session()) {
812 yatra_clear_remaining_session();
813 }
814
815 do_action('yatra_payment_completed', $bookingId, $gateway, $transactionId, [
816 'amount' => $paid_amount,
817 'remaining' => $new_amount_due,
818 'customer_id' => $customerId,
819 'payment_method_id' => $paymentMethodId,
820 ]);
821 }
822
823 /**
824 * Download invoice PDF for a payment
825 */
826 public function download_invoice(WP_REST_Request $request)
827 {
828 $paymentId = (int) $request->get_param('payment_id');
829 $isPreview = $request->get_param('preview') === '1';
830 $isDownload = $request->get_param('download') === '1';
831 $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? ''));
832 $invoiceToken = sanitize_text_field((string) ($request->get_param('invoice_token') ?? ''));
833
834 if ($paymentId <= 0) {
835 return new WP_Error('invalid_payment', __('Invalid payment ID.', 'yatra'), ['status' => 400]);
836 }
837
838 // Get payment with booking details
839 $payment = $this->paymentRepository->findWithBooking($paymentId);
840
841 if (!$payment) {
842 return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]);
843 }
844
845 // Authorisation:
846 // 1. Administrators can always access (no further checks).
847 // 2. Logged-in owner of the booking can access.
848 // 3. Anyone with a valid signed `invoice_token` (HMAC) can access — used on the
849 // booking-confirmation page so guest checkouts and post-session views work.
850 // 4. Legacy guest path: `booking_token` (active checkout transient) — kept for BC.
851 $currentUserId = (int) get_current_user_id();
852 $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0);
853 $paymentBookingId = (int) ($payment->booking_id ?? 0);
854 $isAdmin = current_user_can('manage_options');
855 $authorised = false;
856
857 if ($isAdmin) {
858 $authorised = true;
859 } elseif ($currentUserId && $bookingUserId && $currentUserId === $bookingUserId) {
860 $authorised = true;
861 } elseif ($invoiceToken !== '' && self::verifyInvoiceToken($invoiceToken, (int) $payment->id, $paymentBookingId)) {
862 $authorised = true;
863 } elseif ($bookingToken !== '') {
864 $guestEnabled = (bool) SettingsService::get('allow_guest_checkout', true);
865 if ($guestEnabled) {
866 $session = get_transient($bookingToken);
867 if (is_array($session)) {
868 $sessionBookingId = (int) ($session['booking_id'] ?? 0);
869 if ($sessionBookingId > 0 && $paymentBookingId > 0 && $sessionBookingId === $paymentBookingId) {
870 $authorised = true;
871 }
872 }
873 }
874 }
875
876 if (!$authorised) {
877 if ($currentUserId) {
878 return new WP_Error('forbidden', __('You do not have permission to access this invoice.', 'yatra'), ['status' => 403]);
879 }
880 return new WP_Error('unauthorized', __('You must be logged in to download invoices.', 'yatra'), ['status' => 401]);
881 }
882
883 // Get trip details if available
884 $trip = null;
885 if (!empty($payment->trip_id)) {
886 $trip = $this->tripRepository->find((int) $payment->trip_id);
887 }
888
889 // Get company settings
890 $companyName = SettingsService::get('company_name', get_bloginfo('name'));
891 $companyAddress = SettingsService::get('company_address', '');
892 $companyEmail = SettingsService::get('company_email', get_option('admin_email'));
893 $companyPhone = SettingsService::get('company_phone', '');
894 $currency = SettingsService::getCurrency();
895 $currencySymbol = FormatHelper::getCurrencySymbol($currency);
896
897 // Format dates
898 $paymentDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
899 $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
900
901
902 $bookingRef = (string) ($payment->booking_reference ?? $payment->booking_number ?? $payment->reference ?? (string) $paymentId);
903 $filename = 'Invoice #' . $bookingRef . '.pdf';
904
905
906 $pdfService = new PdfService();
907 if (!$pdfService->isAvailable()) {
908 return new WP_Error(
909 'pdf_engine_missing',
910 __('Invoice PDF generator is not installed. Please run composer install to install dompdf/dompdf.', 'yatra'),
911 ['status' => 500]
912 );
913 }
914
915 // Get tax breakdown for invoice
916 $tax_breakdown = [];
917 $tax_amount = 0;
918 $subtotal = (float) ($payment->booking_total_amount ?? $payment->amount ?? 0);
919
920 if (!empty($payment->tax_details)) {
921 $taxes = json_decode($payment->tax_details, true) ?: [];
922 foreach ($taxes as $tax) {
923 $tax_amount += (float) ($tax['amount'] ?? 0);
924 $tax_breakdown[] = [
925 'name' => $tax['name'] ?? 'Tax',
926 'rate' => $tax['rate'] ?? 0,
927 // Pre-formatted like every other invoice figure, so the tax
928 // rows honour the configured separators and symbol position.
929 'amount' => yatra_format_price((float) ($tax['amount'] ?? 0), $currency, false)
930 ];
931 }
932 // Adjust subtotal for tax-exclusive pricing
933 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
934 $subtotal = (float) ($payment->subtotal ?? $subtotal);
935 }
936 } elseif (!empty($payment->tax_amount) && $payment->tax_amount > 0) {
937 // Single tax fallback
938 $tax_amount = (float) $payment->tax_amount;
939 $tax_breakdown[] = [
940 'name' => __('Tax', 'yatra'),
941 'rate' => (float) ($payment->tax_rate ?? 0),
942 'amount' => yatra_format_price((float) $tax_amount, $currency, false)
943 ];
944 // Adjust subtotal for tax-exclusive pricing
945 if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) {
946 $subtotal = (float) ($payment->subtotal ?? $subtotal);
947 } else {
948 $subtotal = (float) ($payment->subtotal ?? ($subtotal - $tax_amount));
949 }
950 }
951
952 $templateData = [
953 'company_name' => $companyName,
954 'company_address' => $companyAddress,
955 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(),
956 'company_email' => $companyEmail,
957 'company_phone' => $companyPhone,
958 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
959 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
960 'customer_address_lines' => FormatHelper::customerAddressLines($payment),
961 // The booking_payments table has no `reference` column — the payment
962 // reference is a derived value. Mirror PaymentService::formatPayment
963 // (`PAY-%06d`, the same string the React account page shows) so the
964 // invoice's "Invoice #" is populated and consistent, instead of blank.
965 // A real stored reference (if a future join ever provides one) still wins.
966 'payment_ref' => (isset($payment->reference) && (string) $payment->reference !== '')
967 ? (string) $payment->reference
968 : sprintf('PAY-%06d', (int) ($payment->id ?? 0)),
969 'payment_date' => $paymentDate,
970 'payment_status' => ucfirst($payment->status ?? 'paid'),
971 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['paid', 'completed', 'success'], true) ? 'paid' : 'pending',
972 'trip_title' => $trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra'),
973 'payment_method' => ucfirst($payment->gateway ?? $payment->payment_method ?? 'Online'),
974 'booking_ref' => $payment->booking_reference ?? $payment->booking_number ?? '',
975 'travel_date' => $travelDate,
976 'currency_symbol' => $currencySymbol,
977 'amount' => yatra_format_price((float) ($payment->amount ?? 0), $currency, false),
978 'booking_total' => yatra_format_price((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), $currency, false),
979 'amount_paid' => yatra_format_price((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), $currency, false),
980 'amount_due' => yatra_format_price((float) ($payment->booking_amount_due ?? 0), $currency, false),
981 'tax_breakdown' => $tax_breakdown,
982 'tax_amount' => yatra_format_price((float) $tax_amount, $currency, false),
983 'subtotal' => yatra_format_price((float) $subtotal, $currency, false),
984 ];
985
986 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/invoice.php', $templateData, [
987 'paper' => 'A4',
988 'orientation' => 'portrait',
989 'default_font' => 'DejaVu Sans',
990 ]);
991
992 if ($isPreview) {
993 // For preview, return PDF as inline display
994 return new WP_REST_Response([
995 'success' => true,
996 'pdf_data' => base64_encode($pdfBinary),
997 'filename' => $filename,
998 ]);
999 } else {
1000 // For download, output PDF as download
1001 $pdfService->outputPdfDownload($pdfBinary, $filename);
1002 exit;
1003 }
1004 }
1005
1006 /**
1007 * Download a PRO-FORMA invoice for a booking that has no payment yet
1008 * (offline gateways such as Bank Transfer). Shows the amount due and any
1009 * gateway-supplied payment instructions (via yatra_invoice_payment_instructions)
1010 * so the customer knows how to pay. Renders the same pdf/invoice.php template.
1011 */
1012 public function download_booking_invoice(WP_REST_Request $request)
1013 {
1014 $bookingId = (int) $request->get_param('booking_id');
1015 $isPreview = $request->get_param('preview') === '1';
1016 $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? ''));
1017 $invoiceToken = sanitize_text_field((string) ($request->get_param('invoice_token') ?? ''));
1018
1019 if ($bookingId <= 0) {
1020 return new WP_Error('invalid_booking', __('Invalid booking ID.', 'yatra'), ['status' => 400]);
1021 }
1022
1023 $bookingRepository = new \Yatra\Repositories\BookingRepository();
1024 $booking = $bookingRepository->find($bookingId);
1025 if (!$booking) {
1026 return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
1027 }
1028
1029 // Authorisation mirrors download_invoice: admin -> owner -> signed
1030 // booking-scoped invoice_token (paymentId 0) -> guest booking_token.
1031 $currentUserId = (int) get_current_user_id();
1032 $bookingUserId = (int) ($booking->user_id ?? 0);
1033 $authorised = false;
1034 if (current_user_can('manage_options')) {
1035 $authorised = true;
1036 } elseif ($currentUserId && $bookingUserId && $currentUserId === $bookingUserId) {
1037 $authorised = true;
1038 } elseif ($invoiceToken !== '' && self::verifyInvoiceToken($invoiceToken, 0, $bookingId)) {
1039 $authorised = true;
1040 } elseif ($bookingToken !== '' && (bool) SettingsService::get('allow_guest_checkout', true)) {
1041 $session = get_transient($bookingToken);
1042 if (is_array($session) && (int) ($session['booking_id'] ?? 0) === $bookingId) {
1043 $authorised = true;
1044 }
1045 }
1046 if (!$authorised) {
1047 return $currentUserId
1048 ? new WP_Error('forbidden', __('You do not have permission to access this invoice.', 'yatra'), ['status' => 403])
1049 : new WP_Error('unauthorized', __('You must be logged in to download invoices.', 'yatra'), ['status' => 401]);
1050 }
1051
1052 $pdfService = new PdfService();
1053 if (!$pdfService->isAvailable()) {
1054 return new WP_Error('pdf_engine_missing', __('Invoice PDF generator is not installed. Please run composer install to install dompdf/dompdf.', 'yatra'), ['status' => 500]);
1055 }
1056
1057 $trip = !empty($booking->trip_id) ? $this->tripRepository->find((int) $booking->trip_id) : null;
1058
1059 $currency = SettingsService::getCurrency();
1060 $currencySymbol = FormatHelper::getCurrencySymbol($currency);
1061 $bookingRef = (string) ($booking->reference ?? $booking->booking_number ?? (string) $bookingId);
1062 $filename = 'Invoice #' . $bookingRef . '.pdf';
1063 $travelDate = !empty($booking->travel_date) ? date_i18n(get_option('date_format'), strtotime((string) $booking->travel_date)) : '';
1064
1065 $total = (float) ($booking->total_amount ?? 0);
1066 $paid = (float) ($booking->amount_paid ?? 0);
1067 $due = (float) ($booking->amount_due ?? max(0.0, $total - $paid));
1068
1069 // Gateway-supplied payment instructions (Bank Transfer fills this in Pro).
1070 $paymentInstructions = apply_filters('yatra_invoice_payment_instructions', [], $booking);
1071
1072 $templateData = [
1073 'company_name' => SettingsService::get('company_name', get_bloginfo('name')),
1074 'company_address' => SettingsService::get('company_address', ''),
1075 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(),
1076 'company_email' => SettingsService::get('company_email', get_option('admin_email')),
1077 'company_phone' => SettingsService::get('company_phone', ''),
1078 'customer_name' => trim(($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? '')) ?: __('Customer', 'yatra'),
1079 'customer_email' => $booking->contact_email ?? '',
1080 'customer_address_lines' => FormatHelper::customerAddressLines($booking),
1081 'payment_ref' => $bookingRef,
1082 'payment_date' => !empty($booking->created_at) ? date_i18n(get_option('date_format'), strtotime((string) $booking->created_at)) : '',
1083 // Reflect the booking's real payment state rather than a fixed
1084 // "Payment Pending" — a deposit-paid booking is Partially Paid.
1085 'payment_status' => $due <= 0.0
1086 ? __('Paid', 'yatra')
1087 : ($paid > 0.0 ? __('Partially Paid', 'yatra') : __('Payment Pending', 'yatra')),
1088 'status_class' => $due <= 0.0 ? 'paid' : ($paid > 0.0 ? 'partial' : 'pending'),
1089 'trip_title' => $trip->title ?? $booking->trip_title ?? __('Trip Booking', 'yatra'),
1090 'payment_method' => ucwords(str_replace('_', ' ', (string) ($booking->payment_gateway ?? 'offline'))),
1091 'booking_ref' => $bookingRef,
1092 'travel_date' => $travelDate,
1093 'currency_symbol' => $currencySymbol,
1094 'amount' => yatra_format_price((float) $due, $currency, false),
1095 'booking_total' => yatra_format_price((float) $total, $currency, false),
1096 'amount_paid' => yatra_format_price((float) $paid, $currency, false),
1097 'amount_due' => yatra_format_price((float) $due, $currency, false),
1098 'tax_breakdown' => [],
1099 'tax_amount' => yatra_format_price(0.0, $currency, false),
1100 'subtotal' => yatra_format_price((float) $total, $currency, false),
1101 'payment_instructions' => is_array($paymentInstructions) ? $paymentInstructions : [],
1102 ];
1103
1104 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/invoice.php', $templateData, [
1105 'paper' => 'A4',
1106 'orientation' => 'portrait',
1107 'default_font' => 'DejaVu Sans',
1108 ]);
1109
1110 if ($isPreview) {
1111 return new WP_REST_Response([
1112 'success' => true,
1113 'pdf_data' => base64_encode($pdfBinary),
1114 'filename' => $filename,
1115 ]);
1116 }
1117 $pdfService->outputPdfDownload($pdfBinary, $filename);
1118 exit;
1119 }
1120
1121 /**
1122 * Download travel voucher PDF for a booking
1123 */
1124 public function download_voucher(WP_REST_Request $request)
1125 {
1126 $paymentId = (int) $request->get_param('payment_id');
1127 $isPreview = $request->get_param('preview') === '1';
1128 $isDownload = $request->get_param('download') === '1';
1129
1130 if ($paymentId <= 0) {
1131 return new WP_Error('invalid_payment', __('Invalid payment ID.', 'yatra'), ['status' => 400]);
1132 }
1133
1134 // Get payment with booking details
1135 $payment = $this->paymentRepository->findWithBooking($paymentId);
1136
1137 if (!$payment) {
1138 return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]);
1139 }
1140
1141 // Verify user is logged in and owns this payment (or is admin)
1142 $currentUserId = get_current_user_id();
1143 $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0);
1144
1145 // Must be logged in
1146 if (!$currentUserId) {
1147 return new WP_Error('unauthorized', __('You must be logged in to download vouchers.', 'yatra'), ['status' => 401]);
1148 }
1149
1150 // Must own the booking or be admin
1151 if ($bookingUserId && $currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
1152 return new WP_Error('forbidden', __('You do not have permission to access this voucher.', 'yatra'), ['status' => 403]);
1153 }
1154
1155 // Get trip details if available
1156 $trip = null;
1157 if (!empty($payment->trip_id)) {
1158 $trip = $this->tripRepository->find((int) $payment->trip_id);
1159 }
1160
1161 // Get company settings
1162 $companyName = SettingsService::get('company_name', get_bloginfo('name'));
1163 $companyAddress = SettingsService::get('company_address', '');
1164 $companyEmail = SettingsService::get('company_email', get_option('admin_email'));
1165 $companyPhone = SettingsService::get('company_phone', '');
1166 $currency = SettingsService::getCurrency();
1167 $currencySymbol = FormatHelper::getCurrencySymbol($currency);
1168
1169 // Format dates
1170 $bookingDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : '';
1171 $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : '';
1172
1173 // Return date. Prefer the booking's STORED end_date — that is the actual
1174 // booked return (it already accounts for a flexible window or a trip
1175 // duration that changed after the booking was made). Only when no end is
1176 // stored do we derive it from the trip duration: duration_days is
1177 // INCLUSIVE, so the offset is (days - 1) — matching
1178 // BookingRepository::calculateEndDate. A bare "+ duration_days" was one
1179 // day too far (see ItineraryPdfBuilder).
1180 $returnDate = '';
1181 $storedEnd = isset($payment->booking_end_date) ? (string) $payment->booking_end_date : '';
1182 $travelStart = (string) ($payment->travel_date ?? '');
1183 if ($storedEnd !== '' && ($travelStart === '' || $storedEnd >= $travelStart)) {
1184 $returnDate = date_i18n(get_option('date_format'), strtotime($storedEnd));
1185 } else {
1186 $durationDaysForReturn = (int) ($payment->trip_duration_days ?? ($trip->duration_days ?? 0));
1187 if (!empty($payment->travel_date) && $durationDaysForReturn > 0) {
1188 $returnOffset = max(0, $durationDaysForReturn - 1);
1189 $returnTimestamp = strtotime($payment->travel_date . ' +' . $returnOffset . ' days');
1190 $returnDate = date_i18n(get_option('date_format'), $returnTimestamp);
1191 }
1192 }
1193
1194 $bookingRef = (string) ($payment->booking_reference ?? $payment->booking_number ?? $payment->reference ?? (string) $paymentId);
1195 $filename = 'Travel Voucher #' . $bookingRef . '.pdf';
1196
1197 $pdfService = new PdfService();
1198 if (!$pdfService->isAvailable()) {
1199 return new WP_Error(
1200 'pdf_engine_missing',
1201 __('Voucher PDF generator is not installed. Please run composer install to install dompdf/dompdf.', 'yatra'),
1202 ['status' => 500]
1203 );
1204 }
1205
1206 $templateData = [
1207 'company_name' => $companyName,
1208 'company_address' => $companyAddress,
1209 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(),
1210 'company_email' => $companyEmail,
1211 'company_phone' => $companyPhone,
1212 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')),
1213 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '',
1214 'customer_address_lines' => FormatHelper::customerAddressLines($payment),
1215 'booking_ref' => $bookingRef,
1216 'booking_date' => $bookingDate,
1217 'booking_status' => ucfirst($payment->status ?? 'confirmed'),
1218 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' :
1219 (in_array(strtolower((string) ($payment->status ?? '')), ['cancelled'], true) ? 'cancelled' : 'pending'),
1220 'trip_title' => $trip ? ($trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra')) : ($payment->trip_title ?? __('Trip Booking', 'yatra')),
1221 // Trip duration: prefer the duration columns joined onto the payment
1222 // row (always present, even if the trip was later soft-deleted),
1223 // falling back to the loaded trip. There is no `duration` column.
1224 'trip_duration' => yatra_format_duration(
1225 (int) ($payment->trip_duration_days ?? ($trip->duration_days ?? 0)),
1226 isset($payment->trip_duration_nights)
1227 ? (int) $payment->trip_duration_nights
1228 : (isset($trip->duration_nights) ? (int) $trip->duration_nights : null)
1229 ),
1230 'trip_difficulty' => $trip ? ($trip->difficulty_name ?? '') : '',
1231 'departure_location' => $trip ? ($trip->departure_location ?? '') : '',
1232 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''),
1233 'travel_date' => $travelDate,
1234 'return_date' => $returnDate,
1235 'currency_symbol' => $currencySymbol,
1236 'total_amount' => yatra_format_price((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), $currency, false),
1237 'amount_paid' => yatra_format_price((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), $currency, false),
1238 'amount_due' => yatra_format_price((float) ($payment->booking_amount_due ?? 0), $currency, false),
1239 'traveler_count' => (int) ($payment->traveler_count ?? 1),
1240 ];
1241
1242 $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/voucher.php', $templateData, [
1243 'paper' => 'A4',
1244 'orientation' => 'portrait',
1245 'default_font' => 'DejaVu Sans',
1246 ]);
1247
1248 if ($isPreview) {
1249 // For preview, return PDF as inline display
1250 return new WP_REST_Response([
1251 'success' => true,
1252 'pdf_data' => base64_encode($pdfBinary),
1253 'filename' => $filename,
1254 ]);
1255 } else {
1256 // For download, output PDF as download
1257 $pdfService->outputPdfDownload($pdfBinary, $filename);
1258 exit;
1259 }
1260 }
1261
1262 /**
1263 * GET /payments/{payment_id}/itinerary - Download travel itinerary for a payment
1264 */
1265 public function download_itinerary(WP_REST_Request $request)
1266 {
1267 $paymentId = (int) $request->get_param('payment_id');
1268 $isPreview = $request->get_param('preview') === '1';
1269 $isDownload = $request->get_param('download') === '1';
1270
1271 if ($paymentId <= 0) {
1272 return new WP_Error('invalid_payment', __('Invalid payment ID.', 'yatra'), ['status' => 400]);
1273 }
1274
1275 // Get payment with booking details
1276 $payment = $this->paymentRepository->findWithBooking($paymentId);
1277
1278 if (!$payment) {
1279 return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]);
1280 }
1281
1282 // Verify user is logged in and owns this payment (or is admin)
1283 $currentUserId = get_current_user_id();
1284 $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0);
1285
1286 // Must be logged in
1287 if (!$currentUserId) {
1288 return new WP_Error('unauthorized', __('You must be logged in to download itineraries.', 'yatra'), ['status' => 401]);
1289 }
1290
1291 // Must own the booking or be admin
1292 if ($bookingUserId && $currentUserId !== $bookingUserId && !current_user_can('manage_options')) {
1293 return new WP_Error('forbidden', __('You do not have permission to access this itinerary.', 'yatra'), ['status' => 403]);
1294 }
1295
1296 // Delegate all the template-data composition + PDF rendering to
1297 // the shared ItineraryPdfBuilder so the booking-side path
1298 // (BookingsController::renderItineraryFromBookingData) and this
1299 // payment-side path produce IDENTICAL PDFs from the same input.
1300 $builder = new \Yatra\Services\ItineraryPdfBuilder();
1301 if (!$builder->pdfService()->isAvailable()) {
1302 return new WP_Error(
1303 'pdf_engine_missing',
1304 __('Itinerary PDF generator is not installed. Please run composer install to install dompdf/dompdf.', 'yatra'),
1305 ['status' => 500]
1306 );
1307 }
1308
1309 $bookingRef = !empty($payment->booking_id)
1310 ? 'YTR-' . strtoupper(str_pad((string) $payment->booking_id, 8, '0', STR_PAD_LEFT))
1311 : 'PENDING';
1312 $filename = 'Travel-Itinerary-' . $bookingRef . '.pdf';
1313
1314 $pdfBinary = $builder->buildFromPaymentRecord($payment);
1315
1316 if ($isPreview) {
1317 return new WP_REST_Response([
1318 'success' => true,
1319 'pdf_data' => base64_encode($pdfBinary),
1320 'filename' => $filename,
1321 ]);
1322 }
1323
1324 $builder->pdfService()->outputPdfDownload($pdfBinary, $filename);
1325 exit;
1326 }
1327
1328 /**
1329 * Default invoice-token TTL — 1 year. Customers download invoices
1330 * for tax/expense reports months later, so a short TTL would hurt
1331 * legitimate use. The TTL is still meaningful as defense-in-depth:
1332 * a leaked link (forwarded email, posted in a help-desk ticket,
1333 * cached by a public mail relay) eventually expires.
1334 *
1335 * Filterable via `yatra_invoice_token_ttl_seconds` so operators
1336 * can tighten or loosen on a per-site basis.
1337 */
1338 private const INVOICE_TOKEN_DEFAULT_TTL = 365 * 86400;
1339
1340 /**
1341 * Issue a stateless, signed token that grants access to a single
1342 * payment's invoice. v2 format embeds an issued-at timestamp so
1343 * tokens have a defined expiry window — older v1 tokens (no
1344 * expiry component) are still honored by verifyInvoiceToken() so
1345 * pre-existing confirmation emails don't break.
1346 *
1347 * v2 format: `v2.<iat>.<hmac>` where hmac signs `paymentId|bookingId|iat`.
1348 * v1 format: bare `<hmac>` over `paymentId|bookingId` (legacy).
1349 *
1350 * The token is bound to the payment id + booking id and signed
1351 * with the WP auth salt, so it cannot be forged without the site
1352 * secret. It is safe to embed in the confirmation page link so
1353 * guests (or users who logged out after checkout) can still
1354 * download their invoice without a session.
1355 */
1356 public static function issueInvoiceToken(int $paymentId, int $bookingId): string
1357 {
1358 // $paymentId === 0 denotes a booking-scoped (pro-forma) invoice token —
1359 // used for offline/unpaid bookings that have no payment row yet.
1360 if ($paymentId < 0 || $bookingId <= 0) {
1361 return '';
1362 }
1363 $iat = time();
1364 $hmac = hash_hmac(
1365 'sha256',
1366 $paymentId . '|' . $bookingId . '|' . $iat,
1367 wp_salt('auth') . '|yatra_invoice'
1368 );
1369 return 'v2.' . $iat . '.' . $hmac;
1370 }
1371
1372 /**
1373 * Verify a token previously issued by self::issueInvoiceToken().
1374 *
1375 * Accepts both formats:
1376 * - v2 (`v2.<iat>.<hmac>`): validates HMAC + checks token age
1377 * against the configured TTL.
1378 * - v1 (bare hmac, no expiry): legacy tokens already in the
1379 * wild via prior confirmation emails. We accept them
1380 * indefinitely — those URLs were already issued and revoking
1381 * them now would break existing customer bookmarks.
1382 */
1383 public static function verifyInvoiceToken(string $token, int $paymentId, int $bookingId): bool
1384 {
1385 // $paymentId === 0 = booking-scoped (pro-forma) token; see issueInvoiceToken().
1386 if ($token === '' || $paymentId < 0 || $bookingId <= 0) {
1387 return false;
1388 }
1389
1390 // v2 path — token starts with the version prefix.
1391 if (strncmp($token, 'v2.', 3) === 0) {
1392 $parts = explode('.', $token);
1393 if (\count($parts) !== 3) return false;
1394 $iatStr = $parts[1];
1395 $providedHmac = $parts[2];
1396 if (!ctype_digit($iatStr)) return false;
1397 $iat = (int) $iatStr;
1398
1399 $expectedHmac = hash_hmac(
1400 'sha256',
1401 $paymentId . '|' . $bookingId . '|' . $iat,
1402 wp_salt('auth') . '|yatra_invoice'
1403 );
1404 if (!hash_equals($expectedHmac, $providedHmac)) {
1405 return false;
1406 }
1407
1408 $ttl = (int) apply_filters(
1409 'yatra_invoice_token_ttl_seconds',
1410 self::INVOICE_TOKEN_DEFAULT_TTL
1411 );
1412 if ($ttl > 0 && (time() - $iat) > $ttl) {
1413 return false;
1414 }
1415 return true;
1416 }
1417
1418 // v1 legacy path — bare HMAC over (paymentId|bookingId).
1419 // Kept for confirmation emails already sent before the v2
1420 // upgrade landed. New code paths always issue v2.
1421 $expectedLegacy = hash_hmac(
1422 'sha256',
1423 $paymentId . '|' . $bookingId,
1424 wp_salt('auth') . '|yatra_invoice'
1425 );
1426 return hash_equals($expectedLegacy, $token);
1427 }
1428 }
1429