PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.5
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Modules / PaymentMethods / PayPalGateway / PayPal.php

PayPal.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.5, at app/Modules/PaymentMethods/PayPalGateway/PayPal.php

964 lines 36.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\PayPalGateway;
4
5 use FluentCart\Api\CurrencySettings;
6 use FluentCart\Api\Orders;
7 use FluentCart\App\App;
8 use FluentCart\App\Helpers\CartCheckoutHelper;
9 use FluentCart\App\Helpers\CartHelper;
10 use FluentCart\App\Helpers\Helper;
11 use FluentCart\App\Helpers\Status;
12 use FluentCart\App\Hooks\Cart\WebCheckoutHandler;
13 use FluentCart\App\Models\OrderTransaction;
14 use FluentCart\App\Models\Subscription;
15 use FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway;
16 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API;
17 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\Webhook;
18 use FluentCart\App\Services\Payments\PaymentInstance;
19 use FluentCart\App\Vite;
20 use FluentCart\Framework\Support\Arr;
21
22 class PayPal extends AbstractPaymentGateway
23 {
24
25 private $methodSlug = 'paypal';
26
27 public array $supportedFeatures = ['payment', 'refund', 'webhook', 'custom_payment', 'card_update', 'switch_payment_method' => [
28 'supported_gateways' => ['stripe', 'paypal'],
29 ], 'dispute_handler', 'subscriptions'];
30
31
32 public function __construct()
33 {
34 parent::__construct(
35 new PayPalSettingsBase(),
36 new PayPalSubscriptions()
37 );
38
39 add_filter('fluent_cart/payment_methods_with_custom_checkout_buttons', function ($methods) {
40 $methods[] = 'paypal';
41 return $methods;
42 });
43 }
44
45 public function meta(): array
46 {
47 return [
48 'title' => 'PayPal',
49 'route' => 'paypal',
50 'slug' => 'paypal',
51 'label' => 'PayPal',
52 'description' => __('PayPal is the faster, safer way to send and receive money or make an online payment. Get started or create a merchant account to accept payments.', 'fluent-cart'),
53 'logo' => Vite::getAssetUrl("images/payment-methods/paypal-icon.svg"),
54 'icon' => Vite::getAssetUrl("images/payment-methods/paypal-icon.svg"),
55 'brand_color' => '#60cdff',
56 'status' => $this->settings->get('is_active') === 'yes',
57 'upcoming' => false,
58 'supported_features' => $this->supportedFeatures
59 ];
60 }
61
62 public function boot()
63 {
64 (new IPN())->init();
65
66 add_action('wp_ajax_nopriv_fluent_cart_confirm_paypal_payment', [$this, 'confirmPayPalSinglePayment']);
67 add_action('wp_ajax_fluent_cart_confirm_paypal_payment', [$this, 'confirmPayPalSinglePayment']);
68
69 add_action('wp_ajax_nopriv_fluent_cart_confirm_paypal_subscription', [$this, 'confirmPayPalSubscription']);
70 add_action('wp_ajax_fluent_cart_confirm_paypal_subscription', [$this, 'confirmPayPalSubscription']);
71
72 add_filter('fluent_cart/payment_methods/paypal_client_id', [$this, 'getClientId'], 10, 2);
73
74 // add PayPal partner tags
75 add_filter('script_loader_tag', function ($tag, $handle) {
76 if ($handle === 'fluent-cart-checkout-sdk-paypal') {
77 $tag = str_replace(
78 '<script ',
79 '<script data-partner-attribution-id="FLUENTCART_SP_PPCP" ', $tag
80 );
81 }
82 return $tag;
83 }, 1, 2);
84
85 }
86
87 public function makePaymentFromPaymentInstance(PaymentInstance $paymentInstance)
88 {
89 if ($paymentInstance->subscription) {
90 return (new Processor())->handleSubscriptionPaymentFromPaymentInstance($paymentInstance, []);
91 }
92
93 return (new Processor())->handleSinglePayment($paymentInstance, []);
94 }
95
96 public function confirmPayPalSinglePayment()
97 {
98 if (empty(App::request()->get('payId')) || empty(App::request()->get('ref_id'))) {
99 wp_send_json([
100 'status' => 'failed',
101 'message' => __('No payId ID!', 'fluent-cart')
102 ], 422);
103 }
104
105 $payPalReferenceId = sanitize_text_field(App::request()->get('payId'));
106 $transactionHash = sanitize_text_field(App::request()->get('ref_id'));
107
108 $payment_intent = $this->verifyPayPalPayment($payPalReferenceId);
109
110 if (is_wp_error($payment_intent)) {
111 wp_send_json([
112 'status' => 'failed',
113 'message' => $payment_intent->get_error_message(),
114 ], 422);
115 }
116
117 $transaction = null;
118
119 $intendedTransactionHash = Arr::get($payment_intent, 'purchase_units.0.reference_id', '');
120 if ($intendedTransactionHash) {
121 $transaction = OrderTransaction::query()
122 ->where('uuid', $intendedTransactionHash)
123 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
124 ->first();
125 }
126
127 if (!$transaction) {
128 $transaction = OrderTransaction::query()
129 ->where('uuid', $transactionHash)
130 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
131 ->first();
132 }
133
134 if (!$transaction) {
135 wp_send_json([
136 'status' => 'failed',
137 'message' => __('Transaction not found!', 'fluent-cart')
138 ], 423);
139 }
140
141 // Bind the PayPal payment to THIS transaction. FluentCart sets the
142 // transaction uuid as the PayPal order reference_id/custom_id at creation,
143 // so a legitimate confirmation always references it. Requiring the match
144 // prevents a real payment for one order from being applied to an unrelated
145 // order via a forged ref_id in the fallback above.
146 $referencedHashes = [];
147 foreach (Arr::get($payment_intent, 'purchase_units', []) as $unit) {
148 $referencedHashes[] = Arr::get($unit, 'reference_id', '');
149 $referencedHashes[] = Arr::get($unit, 'custom_id', '');
150 }
151 if (!in_array($transaction->uuid, array_filter($referencedHashes), true)) {
152 wp_send_json([
153 'status' => 'failed',
154 'message' => __('Payment does not match this transaction!', 'fluent-cart')
155 ], 422);
156 }
157
158 // Move the money ourselves — never trust the browser to have captured.
159 // FluentCart creates the order with intent=CAPTURE, but the buyer only
160 // AUTHORIZES it in the popup (status APPROVED). The funds are not captured
161 // until we call capture server-side. An APPROVED-but-uncaptured order means
162 // PayPal is holding $0; accepting it as paid delivers the product for free.
163 if (Arr::get($payment_intent, 'status') === 'APPROVED') {
164 $captured = $this->capturePayPalPayment($payPalReferenceId);
165
166 if (is_wp_error($captured)) {
167 // The normal (non-malicious) flow captures in the browser first, so by
168 // the time we reach here the order may already be captured. That is
169 // success, not failure: re-read the order and continue. Any other
170 // capture error is fatal.
171 if (!$this->isAlreadyCapturedError($captured)) {
172 wp_send_json([
173 'status' => 'failed',
174 'message' => $captured->get_error_message(),
175 ], 422);
176 }
177
178 $payment_intent = $this->verifyPayPalPayment($payPalReferenceId);
179 if (is_wp_error($payment_intent)) {
180 wp_send_json([
181 'status' => 'failed',
182 'message' => $payment_intent->get_error_message(),
183 ], 422);
184 }
185 } else {
186 $payment_intent = $captured;
187 }
188 }
189
190 // Only a COMPLETED order (its capture actually moved money) counts as paid.
191 // APPROVED is deliberately NOT accepted here.
192 if (Arr::get($payment_intent, 'status') !== 'COMPLETED') {
193 wp_send_json([
194 'status' => 'failed',
195 'message' => __('Payment not completed!', 'fluent-cart')
196 ], 422);
197 }
198
199 $paidAmount = 0;
200 $paidCurrency = '';
201 foreach (Arr::get($payment_intent, 'purchase_units', []) as $unit) {
202 $paidAmount += Helper::toCent(Arr::get($unit, 'amount.value', 0));
203 if (!$paidCurrency) {
204 $paidCurrency = strtoupper(Arr::get($unit, 'amount.currency_code', ''));
205 }
206 }
207
208 if ($paidAmount != $transaction->total) {
209 fluent_cart_warning_log(
210 __('PayPal Amount Mismatch Attempt', 'fluent-cart'),
211 sprintf(
212 /* translators: %1$s: expected amount, %2$s: received amount */
213 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. This may indicate payment tampering.', 'fluent-cart'),
214 Helper::toDecimal($transaction->total),
215 Helper::toDecimal($paidAmount)
216 ),
217 [
218 'module_name' => 'order',
219 'module_id' => $transaction->order_id,
220 'log_type' => 'api'
221 ]
222 );
223 wp_send_json([
224 'status' => 'failed',
225 'message' => __('Paid amount does not match with transaction amount!', 'fluent-cart')
226 ], 422);
227 }
228
229 if ($paidCurrency && $transaction->currency && strtoupper($transaction->currency) !== $paidCurrency) {
230 fluent_cart_warning_log(
231 __('PayPal Currency Mismatch Attempt', 'fluent-cart'),
232 sprintf(
233 /* translators: %1$s: expected currency, %2$s: received currency */
234 __('Payment currency mismatch detected. Expected: %1$s, Received: %2$s. This may indicate payment tampering.', 'fluent-cart'),
235 $transaction->currency,
236 $paidCurrency
237 ),
238 [
239 'module_name' => 'order',
240 'module_id' => $transaction->order_id,
241 'log_type' => 'api'
242 ]
243 );
244 wp_send_json([
245 'status' => 'failed',
246 'message' => __('Payment currency does not match with transaction currency!', 'fluent-cart')
247 ], 422);
248 }
249
250 $capture = Arr::get($payment_intent, 'purchase_units.0.payments.captures.0', []);
251 $chargeId = Arr::get($capture, 'id', '');
252 $captureStatus = Arr::get($capture, 'status', '');
253
254 // A completed order must have a completed capture with a real charge id. A capture
255 // in PENDING (risk/review hold) has moved no money yet: leave the transaction
256 // pending and let the PAYMENT.CAPTURE.COMPLETED webhook finalize it. Never mark the
257 // order paid off an empty charge id or a non-completed capture.
258 if ($captureStatus === 'PENDING') {
259 wp_send_json([
260 'status' => 'pending',
261 'message' => __('Your payment is being reviewed by PayPal. Your order will be confirmed once the payment is completed.', 'fluent-cart')
262 ], 202);
263 }
264
265 if (!$chargeId || $captureStatus !== 'COMPLETED') {
266 wp_send_json([
267 'status' => 'failed',
268 'message' => __('Payment not completed!', 'fluent-cart')
269 ], 422);
270 }
271
272 $duplicateCapture = false;
273
274 $payPalCaptureLockAcquired = $this->acquirePayPalCaptureLock($chargeId);
275 if (!$payPalCaptureLockAcquired) {
276 wp_send_json([
277 'status' => 'failed',
278 'message' => __('Payment confirmation is already processing. Please try again.', 'fluent-cart')
279 ], 409);
280 }
281
282 // Prevent a single PayPal capture from being applied to more than one
283 // transaction (replay/duplicate-capture protection).
284 try {
285 $duplicateCapture = $this->hasExistingPayPalCapture($transaction, $chargeId);
286
287 if (!$duplicateCapture) {
288 // All Verified! Let's update the transaction and order
289 (new Processor())->confirmPaymentSuccessByCharge($transaction, [
290 'vendor_charge_id' => $chargeId,
291 'status' => Status::TRANSACTION_SUCCEEDED,
292 'total' => $paidAmount,
293 'payment_method_type' => 'PayPal',
294 'meta' => [
295 'payer' => Arr::get($payment_intent, 'payer', [])
296 ],
297 'payment_source' => Arr::get($payment_intent, 'payment_source', []),
298 ]);
299 }
300 } finally {
301 if ($payPalCaptureLockAcquired) {
302 $this->releasePayPalCaptureLock($chargeId);
303 }
304 }
305
306 if ($duplicateCapture) {
307 wp_send_json([
308 'status' => 'failed',
309 'message' => __('This PayPal payment has already been processed!', 'fluent-cart')
310 ], 422);
311 }
312
313 wp_send_json([
314 'status' => 'success',
315 'redirect_url' => $transaction->getReceiptPageUrl(true),
316 'order' => [
317 'uuid' => $transaction->order->uuid
318 ],
319 'message' => __('Payment has been paid successfully! Redirecting...', 'fluent-cart')
320 ]);
321 }
322
323 public function confirmPayPalSubscription()
324 {
325 if (empty(App::request()->get('subscription_id')) || empty(App::request()->get('ref_id'))) {
326 wp_send_json([
327 'status' => 'failed',
328 'message' => __('No Subscription ID!', 'fluent-cart')
329 ], 423);
330 }
331
332 $subscriptionId = sanitize_text_field(App::request()->get('subscription_id'));
333
334 $paypalSubscription = $this->getPayPalSubscription($subscriptionId);
335
336 if (is_wp_error($paypalSubscription)) {
337 wp_send_json([
338 'message' => $paypalSubscription->get_error_message(),
339 'status' => 'failed',
340 ], 422);
341 }
342
343
344 $status = Arr::get($paypalSubscription, 'status', '');
345
346 if ($status != 'ACTIVE') {
347 wp_send_json([
348 'status' => 'failed',
349 'message' => __('Subscription is not active', 'fluent-cart')
350 ], 423);
351 }
352
353 $transaction = OrderTransaction::query()->where('uuid', sanitize_text_field(App::request()->get('ref_id')))->first();
354
355 if (!$transaction) {
356 wp_send_json([
357 'status' => 'failed',
358 'message' => __('Transaction not found!', 'fluent-cart')
359 ], 404);
360 }
361
362 $localSubscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
363
364 if (!$localSubscription) {
365 wp_send_json([
366 'status' => 'failed',
367 'message' => __('Subscription not found!', 'fluent-cart')
368 ], 404);
369 }
370
371 // Bind the PayPal subscription to THIS local subscription. FluentCart sets
372 // the local subscription uuid as the PayPal subscription custom_id at
373 // creation (the same field the IPN webhook resolves by), so a forged ref_id
374 // cannot point an unrelated active PayPal subscription at another customer's
375 // transaction.
376 $paypalCustomId = Arr::get($paypalSubscription, 'custom_id', '');
377 if ($paypalCustomId !== $localSubscription->uuid) {
378 wp_send_json([
379 'status' => 'failed',
380 'message' => __('PayPal subscription does not match this transaction!', 'fluent-cart')
381 ], 422);
382 }
383
384 // Prevent the same PayPal subscription from being bound to more than one
385 // local subscription (reuse protection).
386 $alreadyUsed = Subscription::query()
387 ->where('vendor_subscription_id', $subscriptionId)
388 ->where('id', '!=', $localSubscription->id)
389 ->first();
390 if ($alreadyUsed) {
391 wp_send_json([
392 'status' => 'failed',
393 'message' => __('This PayPal subscription has already been used!', 'fluent-cart')
394 ], 422);
395 }
396
397 // Verify the PayPal subscription's plan matches the expected plan
398 if ($localSubscription && $localSubscription->vendor_plan_id) {
399 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
400 if ($paypalPlanId && $paypalPlanId !== $localSubscription->vendor_plan_id) {
401 fluent_cart_add_log(
402 'PayPal Subscription Plan Mismatch',
403 'The PayPal subscription plan ID does not match the expected plan ID for this subscription. This may indicate a configuration issue or potential tampering.',
404 [
405 'module_name' => 'subscription',
406 'module_id' => $localSubscription->id,
407 'log_type' => 'api'
408 ]
409 );
410
411 wp_send_json([
412 'status' => 'failed',
413 'message' => __('PayPal subscription plan does not match the expected plan.', 'fluent-cart')
414 ], 422);
415 }
416 }
417
418 $subscriptionModel = (new Processor())->activateSubscription($paypalSubscription, $transaction);
419
420 if (!$subscriptionModel || !in_array($subscriptionModel->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING], true)) {
421 wp_send_json([
422 'status' => 'failed',
423 'message' => __('Subscription activation failed.', 'fluent-cart')
424 ], 422);
425 }
426
427 wp_send_json([
428 'status' => 'success',
429 'message' => __('Subscription has been activated successfully!', 'fluent-cart'),
430 'redirect_url' => $transaction->getReceiptPageUrl(true),
431 'order' => [
432 'uuid' => $transaction->order->uuid
433 ],
434 ], 200);
435 }
436
437 protected function getPayPalSubscription($subscriptionId)
438 {
439 return API::getResource('billing/subscriptions/' . $subscriptionId);
440 }
441
442 protected function verifyPayPalPayment($payPalReferenceId)
443 {
444 return API::verifyPayment($payPalReferenceId);
445 }
446
447 protected function capturePayPalPayment($payPalReferenceId)
448 {
449 return API::captureOrder($payPalReferenceId);
450 }
451
452 /**
453 * Detects PayPal's "this order was already captured" response. In the normal flow the
454 * browser captures first, so our server-side capture of the same order legitimately
455 * fails with 422 UNPROCESSABLE_ENTITY / issue ORDER_ALREADY_CAPTURED — that is expected
456 * and must be treated as success (re-GET the order), not as a payment failure.
457 *
458 * @param \WP_Error $error
459 * @return bool
460 */
461 protected function isAlreadyCapturedError($error)
462 {
463 if (!is_wp_error($error)) {
464 return false;
465 }
466
467 if ($error->get_error_code() === 'ORDER_ALREADY_CAPTURED') {
468 return true;
469 }
470
471 $body = $error->get_error_data();
472 if (is_array($body)) {
473 $issue = Arr::get($body, 'details.0.issue', '');
474 if ($issue === 'ORDER_ALREADY_CAPTURED') {
475 return true;
476 }
477 }
478
479 return false;
480 }
481
482 protected function hasExistingPayPalCapture(OrderTransaction $transaction, $chargeId)
483 {
484 return (bool) OrderTransaction::query()
485 ->where('vendor_charge_id', $chargeId)
486 ->where('id', '!=', $transaction->id)
487 ->first();
488 }
489
490 protected function acquirePayPalCaptureLock($chargeId)
491 {
492 global $wpdb;
493
494 $result = $wpdb->get_var($wpdb->prepare(
495 'SELECT GET_LOCK(%s, %d)',
496 $this->getPayPalCaptureLockName($chargeId),
497 10
498 ));
499
500 return (string) $result === '1';
501 }
502
503 protected function releasePayPalCaptureLock($chargeId)
504 {
505 global $wpdb;
506
507 $wpdb->get_var($wpdb->prepare(
508 'SELECT RELEASE_LOCK(%s)',
509 $this->getPayPalCaptureLockName($chargeId)
510 ));
511 }
512
513 protected function getPayPalCaptureLockName($chargeId)
514 {
515 return 'fluent_cart_paypal_capture_' . md5($chargeId);
516 }
517
518 public function getClientId($value, $args)
519 {
520 return $this->settings->getPublicKey();
521 }
522
523 public function handleIPN()
524 {
525 if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] != 'POST') {
526 return;
527 }
528
529 (new IPN())->processWebhook();
530 exit(200);
531 }
532
533 public function getTransactionUrl($url, $data)
534 {
535 if (Arr::get($data, 'payment_mode') === 'test') {
536 return 'https://www.sandbox.paypal.com/activity/payment/' . Arr::get($data, 'vendor_charge_id');
537 }
538
539 return 'https://www.paypal.com/activity/payment/' . Arr::get($data, 'vendor_charge_id');
540 }
541
542 public function appAuthenticator($request)
543 {
544 ConnectConfig::parseConnectInfos($request);
545 }
546
547 public function getSubscriptionUrl($url, $data)
548 {
549 if (Arr::get($data, 'payment_mode') === 'test') {
550 return 'https://www.sandbox.paypal.com/billing/subscriptions/' . Arr::get($data, 'vendor_subscription_id');
551 }
552
553 return 'https://www.paypal.com/billing/subscriptions' . Arr::get($data, 'vendor_subscription_id');
554 }
555
556 public static function beforeSettingsUpdate($data, $oldSettings): array
557 {
558 if (Arr::get($data, 'payment_mode') === 'live') {
559 $data['live_client_secret'] = Helper::encryptKey($data['live_client_secret']);
560 } else {
561 $data['test_client_secret'] = Helper::encryptKey($data['test_client_secret']);
562 }
563
564 if (isset($data['define_test_keys'])) {
565 unset($data['define_test_keys']);
566 }
567 if (isset($data['define_live_keys'])) {
568 unset($data['define_live_keys']);
569 }
570 //clean existing access token if exist, fix for: api key change authentication error
571 fluent_cart_update_option('_paypal_access_token_' . Arr::get($data, 'payment_mode'), []);
572
573 return $data;
574 }
575
576 public function isEnabled(): bool
577 {
578 return $this->settings->isActive();
579 }
580
581 /**
582 * Connect configuration should return
583 */
584 public function getConnectInfo()
585 {
586 return ConnectConfig::getConnectConfig();
587 }
588
589 public function disconnect($data)
590 {
591 return ConnectConfig::disconnect($data);
592 }
593
594 public function getWebhookInfo($mode = 'test')
595 {
596 $webhookId = $this->settings->get($mode . '_webhook_id');
597 $webhookEvents = $this->settings->get($mode . '_webhook_events');
598
599 if (!$webhookId || !$webhookEvents) {
600 return false;
601 }
602
603 /**
604 * return string
605 * webhook url also in code formatted and add copy button
606 * webhook id
607 * webhook events (list of events, and every list item should be code formatted), if not empty
608 * $webhookUrl = home_url('/wp-json/fluent-cart/v2/webhook?fct_payment_listener=1&method=paypal')
609 */
610
611 $webhookInfo = '';
612 if ($webhookId) {
613 $webhookInfo .= '<p><b>' . __('Webhook (No further setup needed) :', 'fluent-cart') . '</b><span style="color:green;">Your webhook <code class="copyable-content">' . $webhookId . '</code> is connected!</span> </p>';
614 }
615 if ($webhookEvents) {
616 $webhookInfo .= '<p>' . __('and now watching for Webhook Events listed bellow:', 'fluent-cart') . '</p><p style="word-wrap: break-word;
617 font-size: 12px;" class="copyable-content">';
618 foreach ($webhookEvents as $event) {
619 $webhookInfo .= $event['name'] . ' | ';
620 }
621 $webhookInfo .= '</p>';
622 }
623
624 return $webhookInfo;
625 }
626
627 public function fields()
628 {
629 $testSchema = [
630 'webhook_instruction' => [
631 'value' => Webhook::webhookInstruction(),
632 'label' => __('Webhook Setup', 'fluent-cart'),
633 'type' => 'html_attr'
634 ],
635 'test_webhook_id' => [
636 'value' => '',
637 'placeholder' => 'Webhook ID',
638 'required' => true,
639 'label' => __('Test Webhook ID (Copy the webhook id and paste bellow)', 'fluent-cart'),
640 'type' => 'text'
641 ],
642 ];
643
644 $liveSchema = [
645 'webhook_instruction' => [
646 'value' => Webhook::webhookInstruction(),
647 'label' => __('Webhook Setup', 'fluent-cart'),
648 'type' => 'html_attr'
649 ],
650 'live_webhook_id' => [
651 'value' => '',
652 'placeholder' => 'Webhook ID',
653 'required' => true,
654 'label' => __('Live Webhook ID (Copy the webhook id and paste bellow)', 'fluent-cart'),
655 'type' => 'text'
656 ],
657 ];
658
659 // if not defined property then no need to show webhook instruction
660 if ($this->settings->getProviderType() !== 'api_keys') {
661 $testSchema = [];
662 $liveSchema = [];
663 }
664
665 $payPalFields = array(
666 'notice' => [
667 'value' => $this->renderStoreModeNotice(),
668 'label' => __('PayPal', 'fluent-cart'),
669 'type' => 'notice'
670 ],
671 'payment_mode' => [
672 'type' => 'tabs',
673 'schema' => [
674 [
675 'type' => 'tab',
676 'label' => __('Live credentials', 'fluent-cart'),
677 'value' => 'live',
678 'schema' => $liveSchema
679 ],
680 [
681 'type' => 'tab',
682 'label' => __('Test credentials', 'fluent-cart'),
683 'value' => 'test',
684 'schema' => $testSchema
685 ]
686 ]
687 ],
688 'provider' => array(
689 'value' => $this->settings->getProviderType(),
690 'label' => __('Provider', 'fluent-cart'),
691 'type' => 'provider'
692 ),
693 'webhook_info_test' => array(
694 'info' => $this->getWebhookInfo('test'),
695 'label' => __('Webhook Info', 'fluent-cart'),
696 'type' => 'webhook_info',
697 'mode' => 'test'
698 ),
699 'webhook_info_live' => array(
700 'info' => $this->getWebhookInfo('live'),
701 'label' => __('Webhook Info', 'fluent-cart'),
702 'type' => 'webhook_info',
703 'mode' => 'live'
704 ),
705 'is_pro_item' => array(
706 'value' => 'no',
707 'label' => __('PayPal', 'fluent-cart'),
708 'type' => 'validate'
709 ),
710 );
711
712 return $payPalFields;
713 }
714
715 public function webHookPaymentMethodName()
716 {
717 return $this->methodSlug;
718 }
719
720 public static function validateSettings($data): array
721 {
722 $mode = Arr::get($data, 'payment_mode', 'test');
723 $provider = Arr::get($data, 'provider', 'connect');
724
725 if ($provider === 'api_keys') {
726 if ($mode === 'live') {
727 $clientId = defined('FCT_PAYPAL_LIVE_PUBLIC_KEY') ? FCT_PAYPAL_LIVE_PUBLIC_KEY : Arr::get($data, 'live_client_id');
728 $clientSecret = defined('FCT_PAYPAL_LIVE_SECRET_KEY') ? FCT_PAYPAL_LIVE_SECRET_KEY : Arr::get($data, 'live_client_secret');
729 } else {
730 $clientId = defined('FCT_PAYPAL_TEST_PUBLIC_KEY') ? FCT_PAYPAL_TEST_PUBLIC_KEY : Arr::get($data, 'test_client_id');
731 $clientSecret = defined('FCT_PAYPAL_TEST_SECRET_KEY') ? FCT_PAYPAL_TEST_SECRET_KEY : Arr::get($data, 'test_client_secret');
732 }
733
734 return static::validateApiCredentials($clientId, $clientSecret, $mode);
735
736 }
737
738 $clientId = Arr::get($data, "{$mode}_client_id");
739 $clientSecret = Arr::get($data, "{$mode}_client_secret");
740
741 if (!$clientId || !$clientSecret) {
742 return [
743 'status' => 'failed',
744 'message' => $mode === 'live' ? __('PayPal live credentials are required!', 'fluent-cart') : __('PayPal test credentials are required!', 'fluent-cart'),
745 ];
746 }
747
748 return [
749 'status' => 'success',
750 'message' => __('Credentials are valid!', 'fluent-cart')
751 ];
752
753 }
754
755 private static function validateApiCredentials($clientId, $clientSecret, $mode): array
756 {
757 $result = API::validateCredentials($clientId, $clientSecret, $mode);
758
759 if (is_wp_error($result)) {
760 return [
761 'status' => 'failed',
762 'message' => $result->get_error_message()
763 ];
764 }
765
766 return [
767 'status' => 'success',
768 'message' => __('Credentials are valid!', 'fluent-cart')
769 ];
770
771 }
772
773 /*
774 * Default sdk enqueue version is the plugin version
775 * if any sdk require a specific version, then override this method
776 * or to remove a version, return null
777 */
778 public function getEnqueueVersion()
779 {
780 return null;
781 }
782
783 public function getEnqueueScriptSrc($hasSubscription = 'no'): array
784 {
785 if ($this->settings->get('checkout_mode') !== 'paypal_pro') {
786 return [];
787 }
788
789 $clientId = $this->settings->getPublicKey();
790 $clientId = sanitize_text_field($clientId);
791
792 $sdkSrc = 'https://www.paypal.com/sdk/js?client-id=' . $clientId;
793
794 if ('yes' == $hasSubscription) {
795 $sdkSrc = add_query_arg(array('vault' => 'true', 'intent' => 'subscription'), $sdkSrc);
796 } else {
797 $sdkSrc = add_query_arg(array('currency' => strtoupper(CurrencySettings::get('currency')), 'intent' => 'capture'), $sdkSrc);
798 }
799 $sdkSrc = apply_filters('fluent_cart/payments/paypal_sdk_src', $sdkSrc, []);
800
801 return [
802 [
803 'handle' => 'fluent-cart-checkout-sdk-paypal',
804 'src' => $sdkSrc,
805 ],
806 [
807 'handle' => 'fluent-cart-checkout-handler-paypal',
808 'src' => Vite::getEnqueuePath('public/payment-methods/paypal-checkout.js'),
809 'deps' => ['fluent-cart-checkout-sdk-paypal']
810 ]
811 ];
812 }
813
814 public function getLocalizeData(): array
815 {
816 return [
817 'fct_paypal_data' => [
818 'translations' => [
819 'uuid not found' => __('uuid not found', 'fluent-cart'),
820 'Choose any option to continue' => __('Choose any option to continue', 'fluent-cart'),
821 'An unknown error occurred' => __('An unknown error occurred', 'fluent-cart'),
822 'An error occurred while loading PayPal.' => __('An error occurred while loading PayPal.', 'fluent-cart'),
823 'Loading Payment Processor...' => __('Loading Payment Processor...', 'fluent-cart'),
824 'Order creation failed' => __('Order creation failed', 'fluent-cart'),
825 'Not proper order handler' => __('Not proper order handler', 'fluent-cart'),
826 'No Subscription ID' => __('No Subscription ID', 'fluent-cart'),
827 'no processing' => __('no processing', 'fluent-cart'),
828 'not proper order handler' => __('not proper order handler', 'fluent-cart'),
829 'Payment confirmation failed' => __('Payment confirmation failed', 'fluent-cart'),
830 ]
831 ]
832 ];
833 }
834
835 public function processRefund($transaction, $amount, $args)
836 {
837 if (!$amount) {
838 return new \WP_Error(
839 'fluent_cart_stripe_refund_error',
840 __('Refund amount is required.', 'fluent-cart')
841 );
842 }
843
844 return PayPalHelper::processRemoteRefund($transaction, $amount, $args);
845 }
846
847 public function getOrderInfo($data)
848 {
849 $cart = CartHelper::getCart();
850 $checkOutHelper = CartCheckoutHelper::make();
851 $shippingChargeData = (new WebCheckoutHandler())->getShippingChargeData($cart);
852 $shippingCharge = Arr::get($shippingChargeData, 'charge');
853 $totalPrice = $checkOutHelper->getItemsAmountTotal(false) + $shippingCharge;
854
855 $tax = $checkOutHelper->getCart()->checkout_data['tax_data'] ?? [];
856 $taxBehavior = (int) Arr::get($tax, 'tax_behavior', 0);
857 $storeTaxBehavior = (int) Arr::get($tax, 'store_tax_behavior', $taxBehavior);
858
859 if ($taxBehavior === 1) {
860 // Pure exclusive — add all tax including fee tax (tax_total contains both).
861 $totalPrice = $totalPrice + (int) Arr::get($tax, 'tax_total', 0)
862 + (int) Arr::get($tax, 'shipping_tax', 0);
863 } elseif ($taxBehavior === 3) {
864 // Mixed — add only exclusive product tax + fee/shipping if store is exclusive.
865 $totalPrice = $totalPrice + (int) Arr::get($tax, 'exclusive_tax_total', 0);
866 if ($storeTaxBehavior === 1) {
867 $totalPrice = $totalPrice + (int) Arr::get($tax, 'fee_tax', 0)
868 + (int) Arr::get($tax, 'shipping_tax', 0);
869 }
870 }
871
872 $items = $checkOutHelper->getItems();
873 $hasSubscription = $this->validateSubscriptions($items);
874
875 $clientId = $this->settings->getPublicKey();
876
877 if (empty($clientId)) {
878 $message = __('Please provide a valid Client Id!', 'fluent-cart');
879 fluent_cart_add_log('PayPal Credential Validation', $message, 'error', ['log_type' => 'payment']);
880 wp_send_json([
881 'status' => 'failed',
882 'message' => __('No valid Client ID found!', 'fluent-cart')
883 ], 422);
884 }
885
886 $paymentArgs['public_key'] = $clientId;
887
888 $paymentDetails = [
889 'mode' => 'payment',
890 'amount' => number_format(Helper::toDecimalWithoutComma($totalPrice), 2, '.', ''),
891 'currency' => strtoupper(CurrencySettings::get('currency')),
892 ];
893
894 if ($hasSubscription) {
895 $paymentDetails['mode'] = 'subscription';
896 }
897
898 $this->checkCurrencySupport();
899
900 wp_send_json(
901 [
902 'data' => [],
903 'payment_args' => $paymentArgs,
904 'message' => __('Order info retrieved!', 'fluent-cart'),
905 'intent' => $paymentDetails,
906 ],
907 200
908 );
909
910 }
911
912 public function checkCurrencySupport()
913 {
914 $currency = CurrencySettings::get('currency');
915
916 if (!in_array(strtoupper($currency), self::getPaypalSupportedCurrency())) {
917 wp_send_json([
918 'status' => 'failed',
919 'message' => __('PayPal does not support the currency you are using!', 'fluent-cart')
920 ], 422);
921 }
922 }
923
924 public function isCurrencySupported(): bool
925 {
926 $currency = CurrencySettings::get('currency');
927 return in_array(strtoupper($currency), self::getPaypalSupportedCurrency());
928 }
929
930 public static function getPaypalSupportedCurrency(): array
931 {
932 return [
933 'USD', 'EUR', 'GBP', 'AUD', 'CAD', 'JPY', 'NZD', 'CHF', 'HKD', 'SGD', 'SEK', 'DKK', 'PLN', 'NOK', 'HUF', 'CZK', 'ILS', 'MXN', 'MYR', 'BRL', 'PHP', 'TWD', 'THB'
934 ];
935 }
936
937 public function acceptRemoteDispute($transaction, $args = [])
938 {
939 $disputeId = Arr::get($transaction->meta, 'dispute_id');
940 $dispute = (new API())->getResource('customer/disputes/'. $disputeId);
941
942 if (!$disputeId) {
943 $dispute = (new API())->getResource('customer/disputes/'. $disputeId);
944
945 if (is_wp_error($dispute) || empty($dispute['dispute_id'])) {
946 new \WP_Error('No dispute ID found!', __('Please check PayPal if the dispute is already accepted or not!', 'fluent-cart'));
947 }
948
949 $disputeId = Arr::get($dispute, 'dispute_id');
950 }
951
952 $note = Arr::get($args, 'dispute_note', 'Accepted full dispute claim!');
953
954 $closeDispute = (new API())->createResource('customer/disputes/' . $disputeId . '/accept-claim', ['note' => $note]);
955
956 if (is_wp_error($closeDispute)) {
957 return $closeDispute;
958 }
959
960 return $closeDispute;
961 }
962
963 }
964