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

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

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