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 +215 -39 3.0.4 → 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 }