← All changes
|
app/PaymentGateways/Gateways/PayPal/PayPalGateway.php
+215
-39
3.0.3
→
3.0.17
View file →
| @@ -17,8 +17,27 @@ | ||
| 17 | 17 | protected string $icon = 'paypal.svg'; |
| 18 | 18 | protected string $sandboxUrl = 'https://developer.paypal.com/tools/sandbox/'; |
| 19 | 19 | protected array $supports = ['paypal', 'credit_card', 'refunds', 'recurring', 'tokenization']; |
| 20 | 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 | + | |
| 21 | 40 | public function getConfigFields(): array |
| 22 | 41 | { |
| 23 | 42 | return [ |
| 24 | 43 | [ |
| @@ -67,8 +86,28 @@ | ||
| 67 | 86 | 'help_url_live' => 'https://developer.paypal.com/dashboard/applications/live', |
| 68 | 87 | 'help_text' => __('Get Client Secret from the same PayPal app you created', 'yatra'), |
| 69 | 88 | 'show_when' => ['mode' => 'advanced'], |
| 70 | 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 | + ], | |
| 71 | 110 | ]; |
| 72 | 111 | } |
| 73 | 112 | |
| 74 | 113 | /** |
| @@ -152,11 +191,18 @@ | ||
| 152 | 191 | $amount = number_format((float) ($paymentData['amount'] ?? 0), 2, '.', ''); |
| 153 | 192 | $currency = $paymentData['currency'] ?? 'USD'; |
| 154 | 193 | $bookingId = $paymentData['booking_id'] ?? 0; |
| 155 | 194 | $reference = $paymentData['reference'] ?? $bookingId; |
| 156 | - $description = $paymentData['description'] ?? sprintf(__('Booking #%s', 'yatra'), $reference); | |
| 195 | + $description = $paymentData['description'] ?? sprintf( | |
| 196 | + /* translators: %s: booking reference. */ | |
| 197 | + __('Booking #%s', 'yatra'), | |
| 198 | + $reference | |
| 199 | + ); | |
| 157 | 200 | $returnUrl = $paymentData['return_url'] ?? yatra_get_booking_confirmation_url((string) $reference); |
| 158 | - $cancelUrl = $paymentData['cancel_url'] ?? home_url('/book/?payment=cancelled&ref=' . $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)); | |
| 159 | 205 | |
| 160 | 206 | // PayPal Standard base URL |
| 161 | 207 | $isTestMode = \Yatra\Services\SettingsService::isPaymentTestMode(); |
| 162 | 208 | $paypalUrl = $isTestMode |
| @@ -212,9 +258,11 @@ | ||
| 212 | 258 | $currency = $paymentData['currency'] ?? 'USD'; |
| 213 | 259 | $bookingId = $paymentData['booking_id'] ?? 0; |
| 214 | 260 | $referenceForReturn = (string) ($paymentData['reference'] ?? $bookingId); |
| 215 | 261 | $returnUrl = $paymentData['return_url'] ?? yatra_get_booking_confirmation_url($referenceForReturn); |
| 216 | - $cancelUrl = $paymentData['cancel_url'] ?? home_url('/book/?payment=cancelled'); | |
| 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)); | |
| 217 | 265 | $savePayment = !empty($paymentData['save_payment']); |
| 218 | 266 | |
| 219 | 267 | $orderData = [ |
| 220 | 268 | 'intent' => 'CAPTURE', |
| @@ -219,9 +267,13 @@ | ||
| 219 | 267 | $orderData = [ |
| 220 | 268 | 'intent' => 'CAPTURE', |
| 221 | 269 | 'purchase_units' => [[ |
| 222 | 270 | 'custom_id' => (string) $bookingId, |
| 223 | - 'description' => $paymentData['description'] ?? sprintf(__('Booking #%s', 'yatra'), $bookingId), | |
| 271 | + 'description' => $paymentData['description'] ?? sprintf( | |
| 272 | + /* translators: %s: booking reference. */ | |
| 273 | + __('Booking #%s', 'yatra'), | |
| 274 | + $bookingId | |
| 275 | + ), | |
| 224 | 276 | 'amount' => [ |
| 225 | 277 | 'currency_code' => $currency, |
| 226 | 278 | 'value' => $amount, |
| 227 | 279 | ], |
| @@ -578,15 +630,40 @@ | ||
| 578 | 630 | $eventType = $event['event_type'] ?? ''; |
| 579 | 631 | |
| 580 | 632 | switch ($eventType) { |
| 581 | 633 | case 'PAYMENT.CAPTURE.COMPLETED': |
| 582 | - do_action('yatra_paypal_payment_completed', $event['resource'] ?? []); | |
| 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); | |
| 583 | 660 | break; |
| 584 | - | |
| 661 | + | |
| 585 | 662 | case 'PAYMENT.CAPTURE.REFUNDED': |
| 586 | 663 | do_action('yatra_paypal_payment_refunded', $event['resource'] ?? []); |
| 587 | 664 | break; |
| 588 | - | |
| 665 | + | |
| 589 | 666 | case 'VAULT.PAYMENT-TOKEN.CREATED': |
| 590 | 667 | do_action('yatra_paypal_token_created', $event['resource'] ?? []); |
| 591 | 668 | break; |
| 592 | 669 | } |
| @@ -592,8 +669,61 @@ | ||
| 592 | 669 | } |
| 593 | 670 | |
| 594 | 671 | return ['success' => true, 'event_type' => $eventType]; |
| 595 | 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 | + } | |
| 596 | 726 | |
| 597 | 727 | /** |
| 598 | 728 | * Handle PayPal IPN (Instant Payment Notification) for Simple mode |
| 599 | 729 | */ |
| @@ -635,24 +765,26 @@ | ||
| 635 | 765 | $amount = (float) ($ipnData['mc_gross'] ?? 0); |
| 636 | 766 | $currency = $ipnData['mc_currency'] ?? 'USD'; |
| 637 | 767 | |
| 638 | 768 | if ($paymentStatus === 'Completed' && $bookingId > 0) { |
| 639 | - // Fire action for payment completed | |
| 640 | - do_action('yatra_payment_completed', [ | |
| 641 | - 'booking_id' => $bookingId, | |
| 642 | - 'transaction_id' => $transactionId, | |
| 643 | - 'amount' => $amount, | |
| 644 | - 'currency' => $currency, | |
| 645 | - 'gateway' => 'paypal', | |
| 646 | - 'mode' => 'simple', | |
| 647 | - ]); | |
| 648 | - | |
| 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 | + | |
| 649 | 781 | $this->log('PayPal IPN payment completed', [ |
| 650 | 782 | 'booking_id' => $bookingId, |
| 651 | 783 | 'transaction_id' => $transactionId, |
| 652 | 784 | 'amount' => $amount, |
| 653 | 785 | ]); |
| 654 | - | |
| 786 | + | |
| 655 | 787 | return ['success' => true, 'status' => 'completed', 'booking_id' => $bookingId]; |
| 656 | 788 | } |
| 657 | 789 | |
| 658 | 790 | return ['success' => true, 'status' => $paymentStatus]; |
| @@ -708,41 +840,82 @@ | ||
| 708 | 840 | |
| 709 | 841 | /** |
| 710 | 842 | * Complete the payment and update booking status |
| 711 | 843 | */ |
| 712 | - private function completePayment($booking, $bookingRepository, string $transactionId): void | |
| 844 | + private function completePayment($booking, $bookingRepository, string $transactionId, array $paymentData = []): void | |
| 713 | 845 | { |
| 714 | 846 | global $wpdb; |
| 715 | - | |
| 847 | + | |
| 848 | + // Already settled in full — never apply another charge to it. | |
| 849 | + if (($booking->payment_status ?? '') === 'paid') { | |
| 850 | + return; | |
| 851 | + } | |
| 852 | + | |
| 716 | 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 | + | |
| 717 | 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'); | |
| 718 | 875 | $previousBookingStatus = (string) ($booking->status ?? 'pending'); |
| 719 | 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 | + | |
| 720 | 888 | // Update booking payment status |
| 721 | 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 | + } | |
| 722 | 902 | $wpdb->update( |
| 723 | 903 | $bookings_table, |
| 724 | - [ | |
| 725 | - 'payment_status' => 'paid', | |
| 726 | - 'amount_paid' => $booking->total_amount, | |
| 727 | - 'amount_due' => 0, | |
| 728 | - 'status' => 'confirmed', | |
| 729 | - 'confirmed_at' => current_time('mysql'), | |
| 730 | - ], | |
| 904 | + $bookingUpdate, | |
| 731 | 905 | ['id' => $bookingId], |
| 732 | - ['%s', '%f', '%f', '%s', '%s'], | |
| 906 | + $bookingUpdateFormat, | |
| 733 | 907 | ['%d'] |
| 734 | 908 | ); |
| 735 | - | |
| 736 | - // Record the payment | |
| 737 | - $payments_table = BookingPaymentsTable::getTableName(); | |
| 909 | + | |
| 910 | + // Record the payment (note: column is `gateway`, not `payment_gateway`). | |
| 738 | 911 | $wpdb->insert( |
| 739 | 912 | $payments_table, |
| 740 | 913 | [ |
| 741 | 914 | 'booking_id' => $bookingId, |
| 742 | - 'amount' => $amountDue, | |
| 743 | - 'currency' => $booking->currency ?? 'USD', | |
| 744 | - 'payment_gateway' => 'paypal', | |
| 915 | + 'amount' => $amount, | |
| 916 | + 'currency' => $currency, | |
| 917 | + 'gateway' => 'paypal', | |
| 745 | 918 | 'transaction_id' => $transactionId, |
| 746 | 919 | 'status' => 'completed', |
| 747 | 920 | 'created_at' => current_time('mysql'), |
| 748 | 921 | ], |
| @@ -747,23 +920,26 @@ | ||
| 747 | 920 | 'created_at' => current_time('mysql'), |
| 748 | 921 | ], |
| 749 | 922 | ['%d', '%f', '%s', '%s', '%s', '%s', '%s'] |
| 750 | 923 | ); |
| 751 | - | |
| 924 | + | |
| 752 | 925 | $this->log('PayPal payment completed', [ |
| 753 | 926 | 'booking_id' => $bookingId, |
| 754 | 927 | 'transaction_id' => $transactionId, |
| 755 | - 'amount' => $amountDue, | |
| 928 | + 'amount' => $amount, | |
| 929 | + 'payment_status' => $paymentStatus, | |
| 756 | 930 | ]); |
| 757 | 931 | |
| 758 | - \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus); | |
| 932 | + if ($shouldConfirm) { | |
| 933 | + \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus, true); | |
| 934 | + } | |
| 759 | 935 | |
| 760 | 936 | // Fire action for other plugins/services |
| 761 | 937 | do_action('yatra_payment_completed', [ |
| 762 | 938 | 'booking_id' => $bookingId, |
| 763 | 939 | 'transaction_id' => $transactionId, |
| 764 | - 'amount' => $amountDue, | |
| 765 | - 'currency' => $booking->currency ?? 'USD', | |
| 940 | + 'amount' => $amount, | |
| 941 | + 'currency' => $currency, | |
| 766 | 942 | 'gateway' => 'paypal', |
| 767 | 943 | ]); |
| 768 | 944 | } |
| 769 | 945 | } |