PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.4
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.4, at app/Modules/PaymentMethods/PayPalGateway/PayPal.php

882 lines 32.7 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 $isPaid = Arr::get($payment_intent, 'status') === 'COMPLETED' || Arr::get($payment_intent, 'status') === 'APPROVED';
159
160 if (!$isPaid) {
161 wp_send_json([
162 'status' => 'failed',
163 'message' => __('Payment not completed!', 'fluent-cart')
164 ], 422);
165 }
166
167 $paidAmount = 0;
168 $paidCurrency = '';
169 foreach (Arr::get($payment_intent, 'purchase_units', []) as $unit) {
170 $paidAmount += Helper::toCent(Arr::get($unit, 'amount.value', 0));
171 if (!$paidCurrency) {
172 $paidCurrency = strtoupper(Arr::get($unit, 'amount.currency_code', ''));
173 }
174 }
175
176 if ($paidAmount != $transaction->total) {
177 fluent_cart_warning_log(
178 __('PayPal Amount Mismatch Attempt', 'fluent-cart'),
179 sprintf(
180 /* translators: %1$s: expected amount, %2$s: received amount */
181 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. This may indicate payment tampering.', 'fluent-cart'),
182 Helper::toDecimal($transaction->total),
183 Helper::toDecimal($paidAmount)
184 ),
185 [
186 'module_name' => 'order',
187 'module_id' => $transaction->order_id,
188 'log_type' => 'api'
189 ]
190 );
191 wp_send_json([
192 'status' => 'failed',
193 'message' => __('Paid amount does not match with transaction amount!', 'fluent-cart')
194 ], 422);
195 }
196
197 if ($paidCurrency && $transaction->currency && strtoupper($transaction->currency) !== $paidCurrency) {
198 fluent_cart_warning_log(
199 __('PayPal Currency Mismatch Attempt', 'fluent-cart'),
200 sprintf(
201 /* translators: %1$s: expected currency, %2$s: received currency */
202 __('Payment currency mismatch detected. Expected: %1$s, Received: %2$s. This may indicate payment tampering.', 'fluent-cart'),
203 $transaction->currency,
204 $paidCurrency
205 ),
206 [
207 'module_name' => 'order',
208 'module_id' => $transaction->order_id,
209 'log_type' => 'api'
210 ]
211 );
212 wp_send_json([
213 'status' => 'failed',
214 'message' => __('Payment currency does not match with transaction currency!', 'fluent-cart')
215 ], 422);
216 }
217
218 $chargeId = Arr::get($payment_intent, 'purchase_units.0.payments.captures.0.id', '');
219
220 $payPalCaptureLockAcquired = false;
221 $duplicateCapture = false;
222
223 if ($chargeId) {
224 $payPalCaptureLockAcquired = $this->acquirePayPalCaptureLock($chargeId);
225 if (!$payPalCaptureLockAcquired) {
226 wp_send_json([
227 'status' => 'failed',
228 'message' => __('Payment confirmation is already processing. Please try again.', 'fluent-cart')
229 ], 409);
230 }
231 }
232
233 // Prevent a single PayPal capture from being applied to more than one
234 // transaction (replay/duplicate-capture protection).
235 try {
236 if ($chargeId) {
237 $duplicateCapture = $this->hasExistingPayPalCapture($transaction, $chargeId);
238 }
239
240 if (!$duplicateCapture) {
241 // All Verified! Let's update the transaction and order
242 (new Processor())->confirmPaymentSuccessByCharge($transaction, [
243 'vendor_charge_id' => $chargeId,
244 'status' => Status::TRANSACTION_SUCCEEDED,
245 'total' => $paidAmount,
246 'payment_method_type' => 'PayPal',
247 'meta' => [
248 'payer' => Arr::get($payment_intent, 'payer', [])
249 ],
250 'payment_source' => Arr::get($payment_intent, 'payment_source', []),
251 ]);
252 }
253 } finally {
254 if ($payPalCaptureLockAcquired) {
255 $this->releasePayPalCaptureLock($chargeId);
256 }
257 }
258
259 if ($duplicateCapture) {
260 wp_send_json([
261 'status' => 'failed',
262 'message' => __('This PayPal payment has already been processed!', 'fluent-cart')
263 ], 422);
264 }
265
266 wp_send_json([
267 'status' => 'success',
268 'redirect_url' => $transaction->getReceiptPageUrl(true),
269 'order' => [
270 'uuid' => $transaction->order->uuid
271 ],
272 'message' => __('Payment has been paid successfully! Redirecting...', 'fluent-cart')
273 ]);
274 }
275
276 public function confirmPayPalSubscription()
277 {
278 if (empty(App::request()->get('subscription_id')) || empty(App::request()->get('ref_id'))) {
279 wp_send_json([
280 'status' => 'failed',
281 'message' => __('No Subscription ID!', 'fluent-cart')
282 ], 423);
283 }
284
285 $subscriptionId = sanitize_text_field(App::request()->get('subscription_id'));
286
287 $paypalSubscription = $this->getPayPalSubscription($subscriptionId);
288
289 if (is_wp_error($paypalSubscription)) {
290 wp_send_json([
291 'message' => $paypalSubscription->get_error_message(),
292 'status' => 'failed',
293 ], 422);
294 }
295
296
297 $status = Arr::get($paypalSubscription, 'status', '');
298
299 if ($status != 'ACTIVE') {
300 wp_send_json([
301 'status' => 'failed',
302 'message' => __('Subscription is not active', 'fluent-cart')
303 ], 423);
304 }
305
306 $transaction = OrderTransaction::query()->where('uuid', sanitize_text_field(App::request()->get('ref_id')))->first();
307
308 if (!$transaction) {
309 wp_send_json([
310 'status' => 'failed',
311 'message' => __('Transaction not found!', 'fluent-cart')
312 ], 404);
313 }
314
315 $localSubscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
316
317 if (!$localSubscription) {
318 wp_send_json([
319 'status' => 'failed',
320 'message' => __('Subscription not found!', 'fluent-cart')
321 ], 404);
322 }
323
324 // Bind the PayPal subscription to THIS local subscription. FluentCart sets
325 // the local subscription uuid as the PayPal subscription custom_id at
326 // creation (the same field the IPN webhook resolves by), so a forged ref_id
327 // cannot point an unrelated active PayPal subscription at another customer's
328 // transaction.
329 $paypalCustomId = Arr::get($paypalSubscription, 'custom_id', '');
330 if ($paypalCustomId !== $localSubscription->uuid) {
331 wp_send_json([
332 'status' => 'failed',
333 'message' => __('PayPal subscription does not match this transaction!', 'fluent-cart')
334 ], 422);
335 }
336
337 // Prevent the same PayPal subscription from being bound to more than one
338 // local subscription (reuse protection).
339 $alreadyUsed = Subscription::query()
340 ->where('vendor_subscription_id', $subscriptionId)
341 ->where('id', '!=', $localSubscription->id)
342 ->first();
343 if ($alreadyUsed) {
344 wp_send_json([
345 'status' => 'failed',
346 'message' => __('This PayPal subscription has already been used!', 'fluent-cart')
347 ], 422);
348 }
349
350 // Verify the PayPal subscription's plan matches the expected plan
351 if ($localSubscription && $localSubscription->vendor_plan_id) {
352 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
353 if ($paypalPlanId && $paypalPlanId !== $localSubscription->vendor_plan_id) {
354 fluent_cart_add_log(
355 'PayPal Subscription Plan Mismatch',
356 'The PayPal subscription plan ID does not match the expected plan ID for this subscription. This may indicate a configuration issue or potential tampering.',
357 [
358 'module_name' => 'subscription',
359 'module_id' => $localSubscription->id,
360 'log_type' => 'api'
361 ]
362 );
363
364 wp_send_json([
365 'status' => 'failed',
366 'message' => __('PayPal subscription plan does not match the expected plan.', 'fluent-cart')
367 ], 422);
368 }
369 }
370
371 $subscriptionModel = (new Processor())->activateSubscription($paypalSubscription, $transaction);
372
373 if (!$subscriptionModel || !in_array($subscriptionModel->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING], true)) {
374 wp_send_json([
375 'status' => 'failed',
376 'message' => __('Subscription activation failed.', 'fluent-cart')
377 ], 422);
378 }
379
380 wp_send_json([
381 'status' => 'success',
382 'message' => __('Subscription has been activated successfully!', 'fluent-cart'),
383 'redirect_url' => $transaction->getReceiptPageUrl(true),
384 'order' => [
385 'uuid' => $transaction->order->uuid
386 ],
387 ], 200);
388 }
389
390 protected function getPayPalSubscription($subscriptionId)
391 {
392 return API::getResource('billing/subscriptions/' . $subscriptionId);
393 }
394
395 protected function verifyPayPalPayment($payPalReferenceId)
396 {
397 return API::verifyPayment($payPalReferenceId);
398 }
399
400 protected function hasExistingPayPalCapture(OrderTransaction $transaction, $chargeId)
401 {
402 return (bool) OrderTransaction::query()
403 ->where('vendor_charge_id', $chargeId)
404 ->where('id', '!=', $transaction->id)
405 ->first();
406 }
407
408 protected function acquirePayPalCaptureLock($chargeId)
409 {
410 global $wpdb;
411
412 $result = $wpdb->get_var($wpdb->prepare(
413 'SELECT GET_LOCK(%s, %d)',
414 $this->getPayPalCaptureLockName($chargeId),
415 10
416 ));
417
418 return (string) $result === '1';
419 }
420
421 protected function releasePayPalCaptureLock($chargeId)
422 {
423 global $wpdb;
424
425 $wpdb->get_var($wpdb->prepare(
426 'SELECT RELEASE_LOCK(%s)',
427 $this->getPayPalCaptureLockName($chargeId)
428 ));
429 }
430
431 protected function getPayPalCaptureLockName($chargeId)
432 {
433 return 'fluent_cart_paypal_capture_' . md5($chargeId);
434 }
435
436 public function getClientId($value, $args)
437 {
438 return $this->settings->getPublicKey();
439 }
440
441 public function handleIPN()
442 {
443 if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] != 'POST') {
444 return;
445 }
446
447 (new IPN())->processWebhook();
448 exit(200);
449 }
450
451 public function getTransactionUrl($url, $data)
452 {
453 if (Arr::get($data, 'payment_mode') === 'test') {
454 return 'https://www.sandbox.paypal.com/activity/payment/' . Arr::get($data, 'vendor_charge_id');
455 }
456
457 return 'https://www.paypal.com/activity/payment/' . Arr::get($data, 'vendor_charge_id');
458 }
459
460 public function appAuthenticator($request)
461 {
462 ConnectConfig::parseConnectInfos($request);
463 }
464
465 public function getSubscriptionUrl($url, $data)
466 {
467 if (Arr::get($data, 'payment_mode') === 'test') {
468 return 'https://www.sandbox.paypal.com/billing/subscriptions/' . Arr::get($data, 'vendor_subscription_id');
469 }
470
471 return 'https://www.paypal.com/billing/subscriptions' . Arr::get($data, 'vendor_subscription_id');
472 }
473
474 public static function beforeSettingsUpdate($data, $oldSettings): array
475 {
476 if (Arr::get($data, 'payment_mode') === 'live') {
477 $data['live_client_secret'] = Helper::encryptKey($data['live_client_secret']);
478 } else {
479 $data['test_client_secret'] = Helper::encryptKey($data['test_client_secret']);
480 }
481
482 if (isset($data['define_test_keys'])) {
483 unset($data['define_test_keys']);
484 }
485 if (isset($data['define_live_keys'])) {
486 unset($data['define_live_keys']);
487 }
488 //clean existing access token if exist, fix for: api key change authentication error
489 fluent_cart_update_option('_paypal_access_token_' . Arr::get($data, 'payment_mode'), []);
490
491 return $data;
492 }
493
494 public function isEnabled(): bool
495 {
496 return $this->settings->isActive();
497 }
498
499 /**
500 * Connect configuration should return
501 */
502 public function getConnectInfo()
503 {
504 return ConnectConfig::getConnectConfig();
505 }
506
507 public function disconnect($data)
508 {
509 return ConnectConfig::disconnect($data);
510 }
511
512 public function getWebhookInfo($mode = 'test')
513 {
514 $webhookId = $this->settings->get($mode . '_webhook_id');
515 $webhookEvents = $this->settings->get($mode . '_webhook_events');
516
517 if (!$webhookId || !$webhookEvents) {
518 return false;
519 }
520
521 /**
522 * return string
523 * webhook url also in code formatted and add copy button
524 * webhook id
525 * webhook events (list of events, and every list item should be code formatted), if not empty
526 * $webhookUrl = home_url('/wp-json/fluent-cart/v2/webhook?fct_payment_listener=1&method=paypal')
527 */
528
529 $webhookInfo = '';
530 if ($webhookId) {
531 $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>';
532 }
533 if ($webhookEvents) {
534 $webhookInfo .= '<p>' . __('and now watching for Webhook Events listed bellow:', 'fluent-cart') . '</p><p style="word-wrap: break-word;
535 font-size: 12px;" class="copyable-content">';
536 foreach ($webhookEvents as $event) {
537 $webhookInfo .= $event['name'] . ' | ';
538 }
539 $webhookInfo .= '</p>';
540 }
541
542 return $webhookInfo;
543 }
544
545 public function fields()
546 {
547 $testSchema = [
548 'webhook_instruction' => [
549 'value' => Webhook::webhookInstruction(),
550 'label' => __('Webhook Setup', 'fluent-cart'),
551 'type' => 'html_attr'
552 ],
553 'test_webhook_id' => [
554 'value' => '',
555 'placeholder' => 'Webhook ID',
556 'required' => true,
557 'label' => __('Test Webhook ID (Copy the webhook id and paste bellow)', 'fluent-cart'),
558 'type' => 'text'
559 ],
560 ];
561
562 $liveSchema = [
563 'webhook_instruction' => [
564 'value' => Webhook::webhookInstruction(),
565 'label' => __('Webhook Setup', 'fluent-cart'),
566 'type' => 'html_attr'
567 ],
568 'live_webhook_id' => [
569 'value' => '',
570 'placeholder' => 'Webhook ID',
571 'required' => true,
572 'label' => __('Live Webhook ID (Copy the webhook id and paste bellow)', 'fluent-cart'),
573 'type' => 'text'
574 ],
575 ];
576
577 // if not defined property then no need to show webhook instruction
578 if ($this->settings->getProviderType() !== 'api_keys') {
579 $testSchema = [];
580 $liveSchema = [];
581 }
582
583 $payPalFields = array(
584 'notice' => [
585 'value' => $this->renderStoreModeNotice(),
586 'label' => __('PayPal', 'fluent-cart'),
587 'type' => 'notice'
588 ],
589 'payment_mode' => [
590 'type' => 'tabs',
591 'schema' => [
592 [
593 'type' => 'tab',
594 'label' => __('Live credentials', 'fluent-cart'),
595 'value' => 'live',
596 'schema' => $liveSchema
597 ],
598 [
599 'type' => 'tab',
600 'label' => __('Test credentials', 'fluent-cart'),
601 'value' => 'test',
602 'schema' => $testSchema
603 ]
604 ]
605 ],
606 'provider' => array(
607 'value' => $this->settings->getProviderType(),
608 'label' => __('Provider', 'fluent-cart'),
609 'type' => 'provider'
610 ),
611 'webhook_info_test' => array(
612 'info' => $this->getWebhookInfo('test'),
613 'label' => __('Webhook Info', 'fluent-cart'),
614 'type' => 'webhook_info',
615 'mode' => 'test'
616 ),
617 'webhook_info_live' => array(
618 'info' => $this->getWebhookInfo('live'),
619 'label' => __('Webhook Info', 'fluent-cart'),
620 'type' => 'webhook_info',
621 'mode' => 'live'
622 ),
623 'is_pro_item' => array(
624 'value' => 'no',
625 'label' => __('PayPal', 'fluent-cart'),
626 'type' => 'validate'
627 ),
628 );
629
630 return $payPalFields;
631 }
632
633 public function webHookPaymentMethodName()
634 {
635 return $this->methodSlug;
636 }
637
638 public static function validateSettings($data): array
639 {
640 $mode = Arr::get($data, 'payment_mode', 'test');
641 $provider = Arr::get($data, 'provider', 'connect');
642
643 if ($provider === 'api_keys') {
644 if ($mode === 'live') {
645 $clientId = defined('FCT_PAYPAL_LIVE_PUBLIC_KEY') ? FCT_PAYPAL_LIVE_PUBLIC_KEY : Arr::get($data, 'live_client_id');
646 $clientSecret = defined('FCT_PAYPAL_LIVE_SECRET_KEY') ? FCT_PAYPAL_LIVE_SECRET_KEY : Arr::get($data, 'live_client_secret');
647 } else {
648 $clientId = defined('FCT_PAYPAL_TEST_PUBLIC_KEY') ? FCT_PAYPAL_TEST_PUBLIC_KEY : Arr::get($data, 'test_client_id');
649 $clientSecret = defined('FCT_PAYPAL_TEST_SECRET_KEY') ? FCT_PAYPAL_TEST_SECRET_KEY : Arr::get($data, 'test_client_secret');
650 }
651
652 return static::validateApiCredentials($clientId, $clientSecret, $mode);
653
654 }
655
656 $clientId = Arr::get($data, "{$mode}_client_id");
657 $clientSecret = Arr::get($data, "{$mode}_client_secret");
658
659 if (!$clientId || !$clientSecret) {
660 return [
661 'status' => 'failed',
662 'message' => $mode === 'live' ? __('PayPal live credentials are required!', 'fluent-cart') : __('PayPal test credentials are required!', 'fluent-cart'),
663 ];
664 }
665
666 return [
667 'status' => 'success',
668 'message' => __('Credentials are valid!', 'fluent-cart')
669 ];
670
671 }
672
673 private static function validateApiCredentials($clientId, $clientSecret, $mode): array
674 {
675 $result = API::validateCredentials($clientId, $clientSecret, $mode);
676
677 if (is_wp_error($result)) {
678 return [
679 'status' => 'failed',
680 'message' => $result->get_error_message()
681 ];
682 }
683
684 return [
685 'status' => 'success',
686 'message' => __('Credentials are valid!', 'fluent-cart')
687 ];
688
689 }
690
691 /*
692 * Default sdk enqueue version is the plugin version
693 * if any sdk require a specific version, then override this method
694 * or to remove a version, return null
695 */
696 public function getEnqueueVersion()
697 {
698 return null;
699 }
700
701 public function getEnqueueScriptSrc($hasSubscription = 'no'): array
702 {
703 if ($this->settings->get('checkout_mode') !== 'paypal_pro') {
704 return [];
705 }
706
707 $clientId = $this->settings->getPublicKey();
708 $clientId = sanitize_text_field($clientId);
709
710 $sdkSrc = 'https://www.paypal.com/sdk/js?client-id=' . $clientId;
711
712 if ('yes' == $hasSubscription) {
713 $sdkSrc = add_query_arg(array('vault' => 'true', 'intent' => 'subscription'), $sdkSrc);
714 } else {
715 $sdkSrc = add_query_arg(array('currency' => strtoupper(CurrencySettings::get('currency')), 'intent' => 'capture'), $sdkSrc);
716 }
717 $sdkSrc = apply_filters('fluent_cart/payments/paypal_sdk_src', $sdkSrc, []);
718
719 return [
720 [
721 'handle' => 'fluent-cart-checkout-sdk-paypal',
722 'src' => $sdkSrc,
723 ],
724 [
725 'handle' => 'fluent-cart-checkout-handler-paypal',
726 'src' => Vite::getEnqueuePath('public/payment-methods/paypal-checkout.js'),
727 'deps' => ['fluent-cart-checkout-sdk-paypal']
728 ]
729 ];
730 }
731
732 public function getLocalizeData(): array
733 {
734 return [
735 'fct_paypal_data' => [
736 'translations' => [
737 'uuid not found' => __('uuid not found', 'fluent-cart'),
738 'Choose any option to continue' => __('Choose any option to continue', 'fluent-cart'),
739 'An unknown error occurred' => __('An unknown error occurred', 'fluent-cart'),
740 'An error occurred while loading PayPal.' => __('An error occurred while loading PayPal.', 'fluent-cart'),
741 'Loading Payment Processor...' => __('Loading Payment Processor...', 'fluent-cart'),
742 'Order creation failed' => __('Order creation failed', 'fluent-cart'),
743 'Not proper order handler' => __('Not proper order handler', 'fluent-cart'),
744 'No Subscription ID' => __('No Subscription ID', 'fluent-cart'),
745 'no processing' => __('no processing', 'fluent-cart'),
746 'not proper order handler' => __('not proper order handler', 'fluent-cart'),
747 'Payment confirmation failed' => __('Payment confirmation failed', 'fluent-cart'),
748 ]
749 ]
750 ];
751 }
752
753 public function processRefund($transaction, $amount, $args)
754 {
755 if (!$amount) {
756 return new \WP_Error(
757 'fluent_cart_stripe_refund_error',
758 __('Refund amount is required.', 'fluent-cart')
759 );
760 }
761
762 return PayPalHelper::processRemoteRefund($transaction, $amount, $args);
763 }
764
765 public function getOrderInfo($data)
766 {
767 $cart = CartHelper::getCart();
768 $checkOutHelper = CartCheckoutHelper::make();
769 $shippingChargeData = (new WebCheckoutHandler())->getShippingChargeData($cart);
770 $shippingCharge = Arr::get($shippingChargeData, 'charge');
771 $totalPrice = $checkOutHelper->getItemsAmountTotal(false) + $shippingCharge;
772
773 $tax = $checkOutHelper->getCart()->checkout_data['tax_data'] ?? [];
774 $taxBehavior = (int) Arr::get($tax, 'tax_behavior', 0);
775 $storeTaxBehavior = (int) Arr::get($tax, 'store_tax_behavior', $taxBehavior);
776
777 if ($taxBehavior === 1) {
778 // Pure exclusive — add all tax including fee tax (tax_total contains both).
779 $totalPrice = $totalPrice + (int) Arr::get($tax, 'tax_total', 0)
780 + (int) Arr::get($tax, 'shipping_tax', 0);
781 } elseif ($taxBehavior === 3) {
782 // Mixed — add only exclusive product tax + fee/shipping if store is exclusive.
783 $totalPrice = $totalPrice + (int) Arr::get($tax, 'exclusive_tax_total', 0);
784 if ($storeTaxBehavior === 1) {
785 $totalPrice = $totalPrice + (int) Arr::get($tax, 'fee_tax', 0)
786 + (int) Arr::get($tax, 'shipping_tax', 0);
787 }
788 }
789
790 $items = $checkOutHelper->getItems();
791 $hasSubscription = $this->validateSubscriptions($items);
792
793 $clientId = $this->settings->getPublicKey();
794
795 if (empty($clientId)) {
796 $message = __('Please provide a valid Client Id!', 'fluent-cart');
797 fluent_cart_add_log('PayPal Credential Validation', $message, 'error', ['log_type' => 'payment']);
798 wp_send_json([
799 'status' => 'failed',
800 'message' => __('No valid Client ID found!', 'fluent-cart')
801 ], 422);
802 }
803
804 $paymentArgs['public_key'] = $clientId;
805
806 $paymentDetails = [
807 'mode' => 'payment',
808 'amount' => number_format(Helper::toDecimalWithoutComma($totalPrice), 2, '.', ''),
809 'currency' => strtoupper(CurrencySettings::get('currency')),
810 ];
811
812 if ($hasSubscription) {
813 $paymentDetails['mode'] = 'subscription';
814 }
815
816 $this->checkCurrencySupport();
817
818 wp_send_json(
819 [
820 'data' => [],
821 'payment_args' => $paymentArgs,
822 'message' => __('Order info retrieved!', 'fluent-cart'),
823 'intent' => $paymentDetails,
824 ],
825 200
826 );
827
828 }
829
830 public function checkCurrencySupport()
831 {
832 $currency = CurrencySettings::get('currency');
833
834 if (!in_array(strtoupper($currency), self::getPaypalSupportedCurrency())) {
835 wp_send_json([
836 'status' => 'failed',
837 'message' => __('PayPal does not support the currency you are using!', 'fluent-cart')
838 ], 422);
839 }
840 }
841
842 public function isCurrencySupported(): bool
843 {
844 $currency = CurrencySettings::get('currency');
845 return in_array(strtoupper($currency), self::getPaypalSupportedCurrency());
846 }
847
848 public static function getPaypalSupportedCurrency(): array
849 {
850 return [
851 'USD', 'EUR', 'GBP', 'AUD', 'CAD', 'JPY', 'NZD', 'CHF', 'HKD', 'SGD', 'SEK', 'DKK', 'PLN', 'NOK', 'HUF', 'CZK', 'ILS', 'MXN', 'MYR', 'BRL', 'PHP', 'TWD', 'THB'
852 ];
853 }
854
855 public function acceptRemoteDispute($transaction, $args = [])
856 {
857 $disputeId = Arr::get($transaction->meta, 'dispute_id');
858 $dispute = (new API())->getResource('customer/disputes/'. $disputeId);
859
860 if (!$disputeId) {
861 $dispute = (new API())->getResource('customer/disputes/'. $disputeId);
862
863 if (is_wp_error($dispute) || empty($dispute['dispute_id'])) {
864 new \WP_Error('No dispute ID found!', __('Please check PayPal if the dispute is already accepted or not!', 'fluent-cart'));
865 }
866
867 $disputeId = Arr::get($dispute, 'dispute_id');
868 }
869
870 $note = Arr::get($args, 'dispute_note', 'Accepted full dispute claim!');
871
872 $closeDispute = (new API())->createResource('customer/disputes/' . $disputeId . '/accept-claim', ['note' => $note]);
873
874 if (is_wp_error($closeDispute)) {
875 return $closeDispute;
876 }
877
878 return $closeDispute;
879 }
880
881 }
882