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

PayPalGateway.php in Yatra – Travel Booking & Tour Operator Software 3.0.7, at app/PaymentGateways/Gateways/PayPal/PayPalGateway.php

910 lines 35.1 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\PaymentGateways\Gateways\PayPal;
6
7 use Yatra\Database\Tables\BookingsTable;
8 use Yatra\Database\Tables\BookingPaymentsTable;
9 use Yatra\PaymentGateways\AbstractPaymentGateway;
10 use Yatra\PaymentGateways\GatewayUserMessages;
11
12 class PayPalGateway extends AbstractPaymentGateway
13 {
14 protected string $id = 'paypal';
15 protected string $title = 'PayPal';
16 protected string $description = 'Accept PayPal and credit card payments';
17 protected string $icon = 'paypal.svg';
18 protected string $sandboxUrl = 'https://developer.paypal.com/tools/sandbox/';
19 protected array $supports = ['paypal', 'credit_card', 'refunds', 'recurring', 'tokenization'];
20
21 public function getConfigFields(): array
22 {
23 return [
24 [
25 'id' => 'mode',
26 'type' => 'select',
27 'label' => __('Integration Mode', 'yatra'),
28 'description' => __('Choose how to connect PayPal', 'yatra'),
29 'default' => 'simple',
30 'options' => [
31 'simple' => __('Simple (Email Only) - Quick setup, basic payments', 'yatra'),
32 'advanced' => __('Advanced (API) - Refunds, saved cards, scheduled payments', 'yatra'),
33 ],
34 'help_text' => __('Simple mode: Just enter your PayPal email. Advanced mode: Enables refunds, saved payment methods, and scheduled payments.', 'yatra'),
35 ],
36 [
37 'id' => 'email',
38 'type' => 'email',
39 'label' => __('PayPal Email', 'yatra'),
40 'description' => __('Your PayPal account email address', 'yatra'),
41 'placeholder' => '[email protected]',
42 'default' => '',
43 'help_text' => __('Enter the email address associated with your PayPal Business or Premier account.', 'yatra'),
44 'help_url_live' => 'https://www.paypal.com/businesswallet/settings',
45 'show_when' => ['mode' => 'simple'],
46 ],
47 [
48 'id' => 'client_id',
49 'type' => 'text',
50 'label' => __('Client ID', 'yatra'),
51 'description' => __('Your PayPal application client ID', 'yatra'),
52 'placeholder' => 'AeA1QIZXiflr1...',
53 'default' => '',
54 'help_url_test' => 'https://developer.paypal.com/tools/sandbox/',
55 'help_url_live' => 'https://developer.paypal.com/dashboard/applications/live',
56 'help_text' => __('Create an app in PayPal Developer Dashboard to get Client ID', 'yatra'),
57 'show_when' => ['mode' => 'advanced'],
58 ],
59 [
60 'id' => 'client_secret',
61 'type' => 'password',
62 'label' => __('Client Secret', 'yatra'),
63 'description' => __('Your PayPal application client secret', 'yatra'),
64 'placeholder' => 'EC...',
65 'default' => '',
66 'help_url_test' => 'https://developer.paypal.com/tools/sandbox/',
67 'help_url_live' => 'https://developer.paypal.com/dashboard/applications/live',
68 'help_text' => __('Get Client Secret from the same PayPal app you created', 'yatra'),
69 'show_when' => ['mode' => 'advanced'],
70 ],
71 [
72 'id' => 'webhook_url',
73 'type' => 'text',
74 'readonly' => true,
75 'label' => __('Webhook URL', 'yatra'),
76 'description' => __('Add this URL as a webhook in your PayPal app', 'yatra'),
77 'default' => rest_url('yatra/v1/payment/webhook/paypal'),
78 'help_text' => __('In your PayPal app, add this as a webhook and subscribe to the "Payment capture completed" event. This is a reliable backup that confirms bookings even if the customer closes the browser after paying.', 'yatra'),
79 'show_when' => ['mode' => 'advanced'],
80 ],
81 [
82 'id' => 'webhook_id',
83 'type' => 'text',
84 'label' => __('Webhook ID', 'yatra'),
85 'description' => __('Webhook ID from your PayPal app', 'yatra'),
86 'placeholder' => 'WH-...',
87 'default' => '',
88 'help_text' => __('Paste the Webhook ID of the webhook you created above. This enables signed verification of incoming PayPal webhooks.', 'yatra'),
89 'show_when' => ['mode' => 'advanced'],
90 ],
91 ];
92 }
93
94 /**
95 * Check if using simple (email-only) mode
96 */
97 private function isSimpleMode(): bool
98 {
99 return ($this->config['mode'] ?? 'simple') === 'simple';
100 }
101
102 public function isProperlyConfigured(): bool
103 {
104 if ($this->isSimpleMode()) {
105 return !empty(trim((string) ($this->config['email'] ?? '')));
106 }
107
108 return !empty(trim((string) ($this->config['client_id'] ?? '')))
109 && !empty(trim((string) ($this->config['client_secret'] ?? '')));
110 }
111
112 private function getBaseUrl(): string
113 {
114 return \Yatra\Services\SettingsService::isPaymentTestMode()
115 ? 'https://api-m.sandbox.paypal.com'
116 : 'https://api-m.paypal.com';
117 }
118
119 private function getAccessToken(): ?string
120 {
121 $response = $this->makeRequest($this->getBaseUrl() . '/v1/oauth2/token', [
122 'method' => 'POST',
123 'headers' => [
124 'Authorization' => 'Basic ' . base64_encode(($this->config['client_id'] ?? '') . ':' . ($this->config['client_secret'] ?? '')),
125 'Content-Type' => 'application/x-www-form-urlencoded',
126 ],
127 'body' => 'grant_type=client_credentials',
128 ]);
129
130 return $response['body']['access_token'] ?? null;
131 }
132
133 private function getHeaders(): array
134 {
135 $accessToken = $this->getAccessToken();
136 return [
137 'Authorization' => 'Bearer ' . $accessToken,
138 'Content-Type' => 'application/json',
139 ];
140 }
141
142 public function processPayment(array $paymentData): array
143 {
144 // Log payment attempt for debugging
145 $this->log('Processing PayPal payment', [
146 'mode' => $this->isSimpleMode() ? 'simple' : 'advanced',
147 'amount' => $paymentData['amount'] ?? 0,
148 'currency' => $paymentData['currency'] ?? 'USD',
149 'booking_id' => $paymentData['booking_id'] ?? 0,
150 'test_mode' => \Yatra\Services\SettingsService::isPaymentTestMode(),
151 ]);
152
153 // Use simple or advanced mode based on configuration
154 if ($this->isSimpleMode()) {
155 return $this->processSimplePayment($paymentData);
156 }
157
158 return $this->processAdvancedPayment($paymentData);
159 }
160
161 /**
162 * Process payment using Simple mode (PayPal Standard - email only)
163 * Redirects to PayPal hosted checkout page
164 */
165 private function processSimplePayment(array $paymentData): array
166 {
167 $email = $this->config['email'] ?? '';
168 if (empty($email)) {
169 return ['success' => false, 'error' => GatewayUserMessages::gatewayNotConfigured($this)];
170 }
171
172 $amount = number_format((float) ($paymentData['amount'] ?? 0), 2, '.', '');
173 $currency = $paymentData['currency'] ?? 'USD';
174 $bookingId = $paymentData['booking_id'] ?? 0;
175 $reference = $paymentData['reference'] ?? $bookingId;
176 $description = $paymentData['description'] ?? sprintf(
177 /* translators: %s: booking reference. */
178 __('Booking #%s', 'yatra'),
179 $reference
180 );
181 $returnUrl = $paymentData['return_url'] ?? yatra_get_booking_confirmation_url((string) $reference);
182 $cancelUrl = $paymentData['cancel_url'] ?? home_url('/book/?payment=cancelled&ref=' . $reference);
183
184 // PayPal Standard base URL
185 $isTestMode = \Yatra\Services\SettingsService::isPaymentTestMode();
186 $paypalUrl = $isTestMode
187 ? 'https://www.sandbox.paypal.com/cgi-bin/webscr'
188 : 'https://www.paypal.com/cgi-bin/webscr';
189
190 // Build PayPal Standard payment URL
191 $params = [
192 'cmd' => '_xclick',
193 'business' => $email,
194 'item_name' => $description,
195 'item_number' => $reference,
196 'amount' => $amount,
197 'currency_code' => $currency,
198 'return' => add_query_arg(['paypal' => 'success', 'booking_id' => $bookingId], $returnUrl),
199 'cancel_return' => $cancelUrl,
200 'notify_url' => rest_url('yatra/v1/payment/webhook/paypal'),
201 'custom' => wp_json_encode(['booking_id' => $bookingId, 'reference' => $reference]),
202 'no_shipping' => '1',
203 'no_note' => '1',
204 'rm' => '2', // POST data back to return URL
205 ];
206
207 $redirectUrl = $paypalUrl . '?' . http_build_query($params);
208
209 $this->log('PayPal Simple mode redirect URL created', [
210 'booking_id' => $bookingId,
211 'amount' => $amount,
212 'test_mode' => $isTestMode,
213 ]);
214
215 return [
216 'success' => true,
217 'redirect_url' => $redirectUrl,
218 'transaction_id' => 'pending_' . $bookingId, // Will be updated via IPN
219 'mode' => 'simple',
220 ];
221 }
222
223 /**
224 * Process payment using Advanced mode (PayPal REST API)
225 * Creates order via API and redirects to approval URL
226 */
227 private function processAdvancedPayment(array $paymentData): array
228 {
229 $accessToken = $this->getAccessToken();
230 if (!$accessToken) {
231 $this->log('PayPal authentication failed', ['client_id' => substr($this->config['client_id'] ?? '', 0, 10) . '...']);
232 return ['success' => false, 'error' => __('Failed to authenticate with PayPal. Please check your API credentials.', 'yatra')];
233 }
234
235 $amount = number_format((float) ($paymentData['amount'] ?? 0), 2, '.', '');
236 $currency = $paymentData['currency'] ?? 'USD';
237 $bookingId = $paymentData['booking_id'] ?? 0;
238 $referenceForReturn = (string) ($paymentData['reference'] ?? $bookingId);
239 $returnUrl = $paymentData['return_url'] ?? yatra_get_booking_confirmation_url($referenceForReturn);
240 $cancelUrl = $paymentData['cancel_url'] ?? home_url('/book/?payment=cancelled');
241 $savePayment = !empty($paymentData['save_payment']);
242
243 $orderData = [
244 'intent' => 'CAPTURE',
245 'purchase_units' => [[
246 'custom_id' => (string) $bookingId,
247 'description' => $paymentData['description'] ?? sprintf(
248 /* translators: %s: booking reference. */
249 __('Booking #%s', 'yatra'),
250 $bookingId
251 ),
252 'amount' => [
253 'currency_code' => $currency,
254 'value' => $amount,
255 ],
256 ]],
257 'application_context' => [
258 'return_url' => add_query_arg(['paypal' => 'success', 'booking_id' => $bookingId], $returnUrl),
259 'cancel_url' => $cancelUrl,
260 'user_action' => 'PAY_NOW',
261 'brand_name' => get_bloginfo('name'),
262 ],
263 ];
264
265 // Enable vault for saving payment method
266 if ($savePayment) {
267 $orderData['payment_source'] = [
268 'paypal' => [
269 'experience_context' => [
270 'payment_method_preference' => 'IMMEDIATE_PAYMENT_REQUIRED',
271 'return_url' => add_query_arg(['paypal' => 'success', 'booking_id' => $bookingId], $returnUrl),
272 'cancel_url' => $cancelUrl,
273 ],
274 'attributes' => [
275 'vault' => [
276 'store_in_vault' => 'ON_SUCCESS',
277 'usage_type' => 'MERCHANT',
278 ],
279 ],
280 ],
281 ];
282 }
283
284 $response = $this->makeRequest($this->getBaseUrl() . '/v2/checkout/orders', [
285 'method' => 'POST',
286 'headers' => [
287 'Authorization' => 'Bearer ' . $accessToken,
288 'Content-Type' => 'application/json',
289 ],
290 'body' => wp_json_encode($orderData),
291 ]);
292
293 if (!$response['success'] || empty($response['body']['id'])) {
294 $errorMessage = $response['body']['message'] ?? $response['body']['error_description'] ?? __('Failed to create PayPal order', 'yatra');
295 $this->log('PayPal order creation failed', [
296 'response' => $response,
297 'error' => $errorMessage,
298 ]);
299 return ['success' => false, 'error' => $errorMessage];
300 }
301
302 // Get approval URL
303 $approvalUrl = null;
304 foreach ($response['body']['links'] ?? [] as $link) {
305 if ($link['rel'] === 'approve') {
306 $approvalUrl = $link['href'];
307 break;
308 }
309 }
310
311 if (empty($approvalUrl)) {
312 $this->log('PayPal order created but no approval URL found', ['order_id' => $response['body']['id']]);
313 return ['success' => false, 'error' => __('PayPal order created but redirect URL not found', 'yatra')];
314 }
315
316 $this->log('PayPal order created successfully', [
317 'order_id' => $response['body']['id'],
318 'approval_url' => $approvalUrl,
319 ]);
320
321 return [
322 'success' => true,
323 'order_id' => $response['body']['id'],
324 'transaction_id' => $response['body']['id'],
325 'redirect_url' => $approvalUrl,
326 'approval_url' => $approvalUrl,
327 'client_id' => $this->config['client_id'] ?? '',
328 'sandbox' => \Yatra\Services\SettingsService::isPaymentTestMode(),
329 'mode' => 'advanced',
330 ];
331 }
332
333 /**
334 * Create PayPal customer (for vault)
335 */
336 public function createCustomer(array $customerData): array
337 {
338 $accessToken = $this->getAccessToken();
339 if (!$accessToken) {
340 return ['success' => false, 'error' => __('Failed to authenticate with PayPal', 'yatra')];
341 }
342
343 // PayPal uses vault tokens associated with merchant, not traditional customers
344 // Generate a unique customer reference
345 $customerId = 'yatra_' . md5($customerData['email'] . time());
346
347 return [
348 'success' => true,
349 'customer_id' => $customerId,
350 ];
351 }
352
353 /**
354 * Save payment token after successful vaulted payment
355 */
356 public function savePaymentMethod(string $customerId, array $paymentMethodData): array
357 {
358 // PayPal vault token is returned after successful order capture
359 $vaultId = $paymentMethodData['vault_id'] ?? '';
360
361 if (empty($vaultId)) {
362 return [
363 'success' => false,
364 'error' => __('Vault ID is required', 'yatra'),
365 ];
366 }
367
368 return [
369 'success' => true,
370 'payment_method_id' => $vaultId,
371 'type' => 'paypal',
372 'card_brand' => 'paypal',
373 'card_last4' => substr($paymentMethodData['email'] ?? '', -4),
374 ];
375 }
376
377 /**
378 * Charge saved PayPal payment token
379 */
380 public function chargePaymentMethod(string $customerId, string $paymentMethodId, array $paymentData): array
381 {
382 $accessToken = $this->getAccessToken();
383 if (!$accessToken) {
384 return ['success' => false, 'error' => __('Failed to authenticate with PayPal', 'yatra')];
385 }
386
387 $amount = number_format((float) ($paymentData['amount'] ?? 0), 2, '.', '');
388 $currency = $paymentData['currency'] ?? 'USD';
389 $bookingId = $paymentData['booking_id'] ?? 0;
390
391 // Create order using vaulted payment source
392 $orderData = [
393 'intent' => 'CAPTURE',
394 'purchase_units' => [[
395 'custom_id' => (string) $bookingId,
396 'description' => $paymentData['description'] ?? 'Scheduled Payment',
397 'amount' => [
398 'currency_code' => $currency,
399 'value' => $amount,
400 ],
401 ]],
402 'payment_source' => [
403 'paypal' => [
404 'vault_id' => $paymentMethodId,
405 ],
406 ],
407 ];
408
409 $response = $this->makeRequest($this->getBaseUrl() . '/v2/checkout/orders', [
410 'method' => 'POST',
411 'headers' => [
412 'Authorization' => 'Bearer ' . $accessToken,
413 'Content-Type' => 'application/json',
414 'PayPal-Request-Id' => uniqid('yatra_', true),
415 ],
416 'body' => wp_json_encode($orderData),
417 ]);
418
419 if (!$response['success'] || empty($response['body']['id'])) {
420 return [
421 'success' => false,
422 'error' => $response['body']['message'] ?? __('Failed to create PayPal order', 'yatra'),
423 ];
424 }
425
426 $orderId = $response['body']['id'];
427
428 // Auto-capture for vaulted payments
429 if ($response['body']['status'] === 'COMPLETED') {
430 $captureId = $response['body']['purchase_units'][0]['payments']['captures'][0]['id'] ?? null;
431 return [
432 'success' => true,
433 'transaction_id' => $captureId ?? $orderId,
434 'order_id' => $orderId,
435 'amount' => (float) $amount,
436 'currency' => $currency,
437 'status' => 'completed',
438 ];
439 }
440
441 // If not auto-captured, capture now
442 $captureResponse = $this->makeRequest($this->getBaseUrl() . "/v2/checkout/orders/{$orderId}/capture", [
443 'method' => 'POST',
444 'headers' => [
445 'Authorization' => 'Bearer ' . $accessToken,
446 'Content-Type' => 'application/json',
447 ],
448 ]);
449
450 if ($captureResponse['body']['status'] === 'COMPLETED') {
451 $captureId = $captureResponse['body']['purchase_units'][0]['payments']['captures'][0]['id'] ?? null;
452 return [
453 'success' => true,
454 'transaction_id' => $captureId ?? $orderId,
455 'order_id' => $orderId,
456 'amount' => (float) $amount,
457 'currency' => $currency,
458 'status' => 'completed',
459 ];
460 }
461
462 return [
463 'success' => false,
464 'error' => __('Payment capture failed', 'yatra'),
465 ];
466 }
467
468 /**
469 * Get saved payment methods
470 */
471 public function getPaymentMethods(string $customerId): array
472 {
473 // PayPal vault tokens need to be stored locally
474 // as PayPal doesn't provide a list endpoint for merchant-stored tokens
475 return [];
476 }
477
478 /**
479 * Delete saved payment method
480 */
481 public function deletePaymentMethod(string $paymentMethodId): array
482 {
483 $accessToken = $this->getAccessToken();
484 if (!$accessToken) {
485 return ['success' => false, 'error' => __('Failed to authenticate with PayPal', 'yatra')];
486 }
487
488 $response = $this->makeRequest($this->getBaseUrl() . "/v3/vault/payment-tokens/{$paymentMethodId}", [
489 'method' => 'DELETE',
490 'headers' => [
491 'Authorization' => 'Bearer ' . $accessToken,
492 ],
493 ]);
494
495 return [
496 'success' => $response['code'] === 204 || $response['success'],
497 ];
498 }
499
500 public function verifyPayment(string $transactionId): array
501 {
502 $accessToken = $this->getAccessToken();
503 if (!$accessToken) {
504 return ['success' => false, 'error' => 'Authentication failed'];
505 }
506
507 // First try to get order details
508 $orderResponse = $this->makeRequest($this->getBaseUrl() . "/v2/checkout/orders/{$transactionId}", [
509 'method' => 'GET',
510 'headers' => [
511 'Authorization' => 'Bearer ' . $accessToken,
512 ],
513 ]);
514
515 // If already completed, return success
516 if (($orderResponse['body']['status'] ?? '') === 'COMPLETED') {
517 $vaultId = null;
518 $paymentSource = $orderResponse['body']['payment_source']['paypal'] ?? [];
519 if (!empty($paymentSource['attributes']['vault']['id'])) {
520 $vaultId = $paymentSource['attributes']['vault']['id'];
521 }
522
523 return [
524 'success' => true,
525 'status' => 'COMPLETED',
526 'capture_id' => $orderResponse['body']['purchase_units'][0]['payments']['captures'][0]['id'] ?? null,
527 'vault_id' => $vaultId,
528 ];
529 }
530
531 // If approved, capture the order
532 if (($orderResponse['body']['status'] ?? '') === 'APPROVED') {
533 $response = $this->makeRequest($this->getBaseUrl() . "/v2/checkout/orders/{$transactionId}/capture", [
534 'method' => 'POST',
535 'headers' => [
536 'Authorization' => 'Bearer ' . $accessToken,
537 'Content-Type' => 'application/json',
538 ],
539 ]);
540
541 $status = $response['body']['status'] ?? '';
542
543 // Check for vault ID
544 $vaultId = null;
545 $paymentSource = $response['body']['payment_source']['paypal'] ?? [];
546 if (!empty($paymentSource['attributes']['vault']['id'])) {
547 $vaultId = $paymentSource['attributes']['vault']['id'];
548 }
549
550 return [
551 'success' => $status === 'COMPLETED',
552 'status' => $status,
553 'capture_id' => $response['body']['purchase_units'][0]['payments']['captures'][0]['id'] ?? null,
554 'vault_id' => $vaultId,
555 ];
556 }
557
558 return [
559 'success' => false,
560 'status' => $orderResponse['body']['status'] ?? 'UNKNOWN',
561 ];
562 }
563
564 public function processRefund(string $transactionId, float $amount): array
565 {
566 $accessToken = $this->getAccessToken();
567 if (!$accessToken) {
568 return ['success' => false, 'error' => 'Authentication failed'];
569 }
570
571 $response = $this->makeRequest($this->getBaseUrl() . "/v2/payments/captures/{$transactionId}/refund", [
572 'method' => 'POST',
573 'headers' => [
574 'Authorization' => 'Bearer ' . $accessToken,
575 'Content-Type' => 'application/json',
576 ],
577 'body' => wp_json_encode([
578 'amount' => [
579 'value' => number_format($amount, 2, '.', ''),
580 'currency_code' => 'USD',
581 ],
582 ]),
583 ]);
584
585 return [
586 'success' => $response['success'],
587 'refund_id' => $response['body']['id'] ?? null,
588 ];
589 }
590
591 /**
592 * Handle PayPal webhook (supports both IPN for Simple mode and REST webhooks for Advanced mode)
593 */
594 public function handleWebhook(array $data): array
595 {
596 $body = $data['raw_body'] ?? '';
597 $postData = $data['post_data'] ?? [];
598
599 // Check if this is an IPN notification (Simple mode)
600 if (!empty($postData['txn_type']) || !empty($postData['payment_status'])) {
601 return $this->handleIPN($postData);
602 }
603
604 // Otherwise handle as REST API webhook (Advanced mode)
605 $event = json_decode($body, true);
606 $eventType = $event['event_type'] ?? '';
607
608 switch ($eventType) {
609 case 'PAYMENT.CAPTURE.COMPLETED':
610 $resource = $event['resource'] ?? [];
611
612 // Only confirm from a webhook whose signature we can verify against
613 // the configured Webhook ID. Unverified events are ignored for
614 // confirmation (the return-capture path is authoritative); the
615 // informational action still fires for any custom listeners.
616 if ($this->verifyWebhookSignature($data['headers'] ?? [], $body, $event)) {
617 $bookingId = (int) ($resource['custom_id'] ?? 0);
618 $transactionId = (string) ($resource['id'] ?? '');
619 if ($bookingId > 0 && $transactionId !== '') {
620 $bookingRepository = new \Yatra\Repositories\BookingRepository();
621 $booking = $bookingRepository->find($bookingId);
622 if ($booking) {
623 $this->completePayment($booking, $bookingRepository, $transactionId, [
624 'amount' => (float) ($resource['amount']['value'] ?? 0),
625 'currency' => (string) ($resource['amount']['currency_code'] ?? 'USD'),
626 ]);
627 }
628 }
629 } else {
630 $this->log('PayPal webhook not verified — confirmation skipped (set Webhook ID to enable)', [
631 'event_type' => $eventType,
632 ]);
633 }
634
635 do_action('yatra_paypal_payment_completed', $resource);
636 break;
637
638 case 'PAYMENT.CAPTURE.REFUNDED':
639 do_action('yatra_paypal_payment_refunded', $event['resource'] ?? []);
640 break;
641
642 case 'VAULT.PAYMENT-TOKEN.CREATED':
643 do_action('yatra_paypal_token_created', $event['resource'] ?? []);
644 break;
645 }
646
647 return ['success' => true, 'event_type' => $eventType];
648 }
649
650 /**
651 * Verify an Advanced-mode REST webhook against the configured Webhook ID
652 * using PayPal's verify-webhook-signature API. Returns false when no
653 * Webhook ID is configured, so unverified events are never trusted.
654 */
655 private function verifyWebhookSignature(array $headers, string $rawBody, array $event): bool
656 {
657 $webhookId = trim((string) ($this->config['webhook_id'] ?? ''));
658 if ($webhookId === '') {
659 return false;
660 }
661
662 $accessToken = $this->getAccessToken();
663 if (!$accessToken) {
664 return false;
665 }
666
667 $header = static function (string $name) use ($headers): string {
668 // WP REST normalises header keys to lowercase, with dashes or underscores.
669 foreach ([$name, str_replace('-', '_', $name)] as $key) {
670 if (isset($headers[$key])) {
671 return (string) (is_array($headers[$key]) ? ($headers[$key][0] ?? '') : $headers[$key]);
672 }
673 }
674 return '';
675 };
676
677 $payload = [
678 'auth_algo' => $header('paypal-auth-algo'),
679 'cert_url' => $header('paypal-cert-url'),
680 'transmission_id' => $header('paypal-transmission-id'),
681 'transmission_sig' => $header('paypal-transmission-sig'),
682 'transmission_time' => $header('paypal-transmission-time'),
683 'webhook_id' => $webhookId,
684 'webhook_event' => $event,
685 ];
686
687 if ($payload['transmission_id'] === '' || $payload['transmission_sig'] === '') {
688 return false;
689 }
690
691 $response = $this->makeRequest($this->getBaseUrl() . '/v1/notifications/verify-webhook-signature', [
692 'method' => 'POST',
693 'headers' => [
694 'Authorization' => 'Bearer ' . $accessToken,
695 'Content-Type' => 'application/json',
696 ],
697 'body' => wp_json_encode($payload),
698 ]);
699
700 return ($response['body']['verification_status'] ?? '') === 'SUCCESS';
701 }
702
703 /**
704 * Handle PayPal IPN (Instant Payment Notification) for Simple mode
705 */
706 private function handleIPN(array $ipnData): array
707 {
708 $this->log('PayPal IPN received', $ipnData);
709
710 // Verify IPN with PayPal
711 $isTestMode = \Yatra\Services\SettingsService::isPaymentTestMode();
712 $verifyUrl = $isTestMode
713 ? 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr'
714 : 'https://ipnpb.paypal.com/cgi-bin/webscr';
715
716 $verifyData = array_merge(['cmd' => '_notify-validate'], $ipnData);
717
718 $response = wp_remote_post($verifyUrl, [
719 'body' => $verifyData,
720 'timeout' => 60,
721 'httpversion' => '1.1',
722 ]);
723
724 if (is_wp_error($response)) {
725 $this->log('IPN verification failed', ['error' => $response->get_error_message()]);
726 return ['success' => false, 'error' => 'IPN verification failed'];
727 }
728
729 $responseBody = wp_remote_retrieve_body($response);
730
731 if ($responseBody !== 'VERIFIED') {
732 $this->log('IPN not verified', ['response' => $responseBody]);
733 return ['success' => false, 'error' => 'IPN not verified'];
734 }
735
736 // Process the payment
737 $paymentStatus = $ipnData['payment_status'] ?? '';
738 $customData = json_decode($ipnData['custom'] ?? '{}', true);
739 $bookingId = $customData['booking_id'] ?? 0;
740 $transactionId = $ipnData['txn_id'] ?? '';
741 $amount = (float) ($ipnData['mc_gross'] ?? 0);
742 $currency = $ipnData['mc_currency'] ?? 'USD';
743
744 if ($paymentStatus === 'Completed' && $bookingId > 0) {
745 // Record the payment + confirm the booking. completePayment is
746 // idempotent (and fires `yatra_payment_completed` itself), so a
747 // re-sent IPN won't double-record.
748 $bookingRepository = new \Yatra\Repositories\BookingRepository();
749 $booking = $bookingRepository->find((int) $bookingId);
750 if ($booking) {
751 $this->completePayment($booking, $bookingRepository, $transactionId, [
752 'amount' => $amount,
753 'currency' => $currency,
754 ]);
755 }
756
757 $this->log('PayPal IPN payment completed', [
758 'booking_id' => $bookingId,
759 'transaction_id' => $transactionId,
760 'amount' => $amount,
761 ]);
762
763 return ['success' => true, 'status' => 'completed', 'booking_id' => $bookingId];
764 }
765
766 return ['success' => true, 'status' => $paymentStatus];
767 }
768
769 /**
770 * Check if this gateway should handle the return request
771 */
772 public function shouldHandleReturn(array $params): bool
773 {
774 return isset($params['paypal']) && $params['paypal'] === 'success';
775 }
776
777 /**
778 * Handle payment return from PayPal (when user is redirected back after payment)
779 * Works for both Simple and Advanced modes
780 */
781 public function handlePaymentReturn($booking, $bookingRepository): void
782 {
783 global $wpdb;
784
785 // Check if payment already processed
786 if ($booking->payment_status === 'paid') {
787 return;
788 }
789
790 $bookingId = (int) $booking->id;
791 $isAdvancedMode = !$this->isSimpleMode();
792
793 $this->log('Handling PayPal return', [
794 'booking_id' => $bookingId,
795 'mode' => $isAdvancedMode ? 'advanced' : 'simple',
796 ]);
797
798 if ($isAdvancedMode) {
799 // Advanced mode: Get token from URL and capture the order
800 $token = sanitize_text_field($_GET['token'] ?? '');
801
802 if (!empty($token)) {
803 $result = $this->verifyPayment($token);
804
805 if ($result['success'] && $result['status'] === 'COMPLETED') {
806 $this->completePayment($booking, $bookingRepository, $result['capture_id'] ?? $token);
807 }
808 }
809 } else {
810 // Simple mode: Payment verification happens via IPN
811 // For now, mark as pending verification - IPN will update it
812 // But we can show success to user since PayPal redirected them back
813 $this->log('Simple mode return - awaiting IPN verification', ['booking_id' => $bookingId]);
814 }
815 }
816
817 /**
818 * Complete the payment and update booking status
819 */
820 private function completePayment($booking, $bookingRepository, string $transactionId, array $paymentData = []): void
821 {
822 global $wpdb;
823
824 // Already settled in full — never apply another charge to it.
825 if (($booking->payment_status ?? '') === 'paid') {
826 return;
827 }
828
829 $bookingId = (int) $booking->id;
830 $payments_table = BookingPaymentsTable::getTableName();
831
832 // Idempotency: bail if this gateway transaction is already recorded for
833 // this booking. Prevents duplicate rows when the confirmation-page return
834 // and the webhook both fire for the same capture. Mirrors StripeGateway.
835 if ($transactionId !== '') {
836 $alreadyRecorded = $wpdb->get_var(
837 $wpdb->prepare(
838 "SELECT id FROM {$payments_table} WHERE booking_id = %d AND transaction_id = %s LIMIT 1",
839 $bookingId,
840 $transactionId
841 )
842 );
843 if ($alreadyRecorded) {
844 return;
845 }
846 }
847
848 $amountDue = (float) ($booking->amount_due ?? ($booking->total_amount - $booking->amount_paid));
849 $amount = (float) ($paymentData['amount'] ?? $amountDue);
850 $currency = $paymentData['currency'] ?? ($booking->currency ?? 'USD');
851 $previousBookingStatus = (string) ($booking->status ?? 'pending');
852
853 // Accumulate paid amount so deposit/partial flows don't get force-marked fully paid.
854 $totalAmount = (float) ($booking->total_amount ?? 0);
855 $newAmountPaid = (float) ($booking->amount_paid ?? 0) + $amount;
856 $newAmountDue = max(0.0, $totalAmount - $newAmountPaid);
857 $paymentStatus = $newAmountDue <= 0.01 ? 'paid' : 'partial';
858
859 // Update booking payment status
860 $bookings_table = BookingsTable::getTableName();
861 $wpdb->update(
862 $bookings_table,
863 [
864 'payment_status' => $paymentStatus,
865 'amount_paid' => $newAmountPaid,
866 'amount_due' => $newAmountDue,
867 'status' => 'confirmed',
868 'confirmed_at' => current_time('mysql'),
869 ],
870 ['id' => $bookingId],
871 ['%s', '%f', '%f', '%s', '%s'],
872 ['%d']
873 );
874
875 // Record the payment (note: column is `gateway`, not `payment_gateway`).
876 $wpdb->insert(
877 $payments_table,
878 [
879 'booking_id' => $bookingId,
880 'amount' => $amount,
881 'currency' => $currency,
882 'gateway' => 'paypal',
883 'transaction_id' => $transactionId,
884 'status' => 'completed',
885 'created_at' => current_time('mysql'),
886 ],
887 ['%d', '%f', '%s', '%s', '%s', '%s', '%s']
888 );
889
890 $this->log('PayPal payment completed', [
891 'booking_id' => $bookingId,
892 'transaction_id' => $transactionId,
893 'amount' => $amount,
894 'payment_status' => $paymentStatus,
895 ]);
896
897 \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus);
898
899 // Fire action for other plugins/services
900 do_action('yatra_payment_completed', [
901 'booking_id' => $bookingId,
902 'transaction_id' => $transactionId,
903 'amount' => $amount,
904 'currency' => $currency,
905 'gateway' => 'paypal',
906 ]);
907 }
908 }
909
910