PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
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.13, at app/PaymentGateways/Gateways/PayPal/PayPalGateway.php

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