PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
← All changes | app/PaymentGateways/Gateways/PayPal/PayPalGateway.php +205 -37 3.0.6 → 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 /**
@@ -158,9 +197,12 @@
158 197 __('Booking #%s', 'yatra'),
159 198 $reference
160 199 );
161 200 $returnUrl = $paymentData['return_url'] ?? yatra_get_booking_confirmation_url((string) $reference);
162 - $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));
163 205
164 206 // PayPal Standard base URL
165 207 $isTestMode = \Yatra\Services\SettingsService::isPaymentTestMode();
166 208 $paypalUrl = $isTestMode
@@ -216,9 +258,11 @@
216 258 $currency = $paymentData['currency'] ?? 'USD';
217 259 $bookingId = $paymentData['booking_id'] ?? 0;
218 260 $referenceForReturn = (string) ($paymentData['reference'] ?? $bookingId);
219 261 $returnUrl = $paymentData['return_url'] ?? yatra_get_booking_confirmation_url($referenceForReturn);
220 - $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));
221 265 $savePayment = !empty($paymentData['save_payment']);
222 266
223 267 $orderData = [
224 268 'intent' => 'CAPTURE',
@@ -586,15 +630,40 @@
586 630 $eventType = $event['event_type'] ?? '';
587 631
588 632 switch ($eventType) {
589 633 case 'PAYMENT.CAPTURE.COMPLETED':
590 - 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);
591 660 break;
592 -
661 +
593 662 case 'PAYMENT.CAPTURE.REFUNDED':
594 663 do_action('yatra_paypal_payment_refunded', $event['resource'] ?? []);
595 664 break;
596 -
665 +
597 666 case 'VAULT.PAYMENT-TOKEN.CREATED':
598 667 do_action('yatra_paypal_token_created', $event['resource'] ?? []);
599 668 break;
600 669 }
@@ -600,8 +669,61 @@
600 669 }
601 670
602 671 return ['success' => true, 'event_type' => $eventType];
603 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 + }
604 726
605 727 /**
606 728 * Handle PayPal IPN (Instant Payment Notification) for Simple mode
607 729 */
@@ -643,24 +765,26 @@
643 765 $amount = (float) ($ipnData['mc_gross'] ?? 0);
644 766 $currency = $ipnData['mc_currency'] ?? 'USD';
645 767
646 768 if ($paymentStatus === 'Completed' && $bookingId > 0) {
647 - // Fire action for payment completed
648 - do_action('yatra_payment_completed', [
649 - 'booking_id' => $bookingId,
650 - 'transaction_id' => $transactionId,
651 - 'amount' => $amount,
652 - 'currency' => $currency,
653 - 'gateway' => 'paypal',
654 - 'mode' => 'simple',
655 - ]);
656 -
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 +
657 781 $this->log('PayPal IPN payment completed', [
658 782 'booking_id' => $bookingId,
659 783 'transaction_id' => $transactionId,
660 784 'amount' => $amount,
661 785 ]);
662 -
786 +
663 787 return ['success' => true, 'status' => 'completed', 'booking_id' => $bookingId];
664 788 }
665 789
666 790 return ['success' => true, 'status' => $paymentStatus];
@@ -716,41 +840,82 @@
716 840
717 841 /**
718 842 * Complete the payment and update booking status
719 843 */
720 - private function completePayment($booking, $bookingRepository, string $transactionId): void
844 + private function completePayment($booking, $bookingRepository, string $transactionId, array $paymentData = []): void
721 845 {
722 846 global $wpdb;
723 -
847 +
848 + // Already settled in full — never apply another charge to it.
849 + if (($booking->payment_status ?? '') === 'paid') {
850 + return;
851 + }
852 +
724 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 +
725 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');
726 875 $previousBookingStatus = (string) ($booking->status ?? 'pending');
727 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 +
728 888 // Update booking payment status
729 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 + }
730 902 $wpdb->update(
731 903 $bookings_table,
732 - [
733 - 'payment_status' => 'paid',
734 - 'amount_paid' => $booking->total_amount,
735 - 'amount_due' => 0,
736 - 'status' => 'confirmed',
737 - 'confirmed_at' => current_time('mysql'),
738 - ],
904 + $bookingUpdate,
739 905 ['id' => $bookingId],
740 - ['%s', '%f', '%f', '%s', '%s'],
906 + $bookingUpdateFormat,
741 907 ['%d']
742 908 );
743 -
744 - // Record the payment
745 - $payments_table = BookingPaymentsTable::getTableName();
909 +
910 + // Record the payment (note: column is `gateway`, not `payment_gateway`).
746 911 $wpdb->insert(
747 912 $payments_table,
748 913 [
749 914 'booking_id' => $bookingId,
750 - 'amount' => $amountDue,
751 - 'currency' => $booking->currency ?? 'USD',
752 - 'payment_gateway' => 'paypal',
915 + 'amount' => $amount,
916 + 'currency' => $currency,
917 + 'gateway' => 'paypal',
753 918 'transaction_id' => $transactionId,
754 919 'status' => 'completed',
755 920 'created_at' => current_time('mysql'),
756 921 ],
@@ -755,23 +920,26 @@
755 920 'created_at' => current_time('mysql'),
756 921 ],
757 922 ['%d', '%f', '%s', '%s', '%s', '%s', '%s']
758 923 );
759 -
924 +
760 925 $this->log('PayPal payment completed', [
761 926 'booking_id' => $bookingId,
762 927 'transaction_id' => $transactionId,
763 - 'amount' => $amountDue,
928 + 'amount' => $amount,
929 + 'payment_status' => $paymentStatus,
764 930 ]);
765 931
766 - \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus);
932 + if ($shouldConfirm) {
933 + \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus, true);
934 + }
767 935
768 936 // Fire action for other plugins/services
769 937 do_action('yatra_payment_completed', [
770 938 'booking_id' => $bookingId,
771 939 'transaction_id' => $transactionId,
772 - 'amount' => $amountDue,
773 - 'currency' => $booking->currency ?? 'USD',
940 + 'amount' => $amount,
941 + 'currency' => $currency,
774 942 'gateway' => 'paypal',
775 943 ]);
776 944 }
777 945 }