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

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

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