PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.1
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 trunk All 48 releases
fluent-cart / app / Modules / PaymentMethods / PayPalGateway / PayPal.php

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

1,333 lines 51.9 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\App\App;
7 use FluentCart\App\Helpers\CartCheckoutHelper;
8 use FluentCart\App\Helpers\CartHelper;
9 use FluentCart\App\Helpers\Helper;
10 use FluentCart\App\Helpers\Status;
11 use FluentCart\App\Hooks\Cart\WebCheckoutHandler;
12 use FluentCart\App\Models\OrderTransaction;
13 use FluentCart\App\Models\Subscription;
14 use FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway;
15 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API;
16 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\Webhook;
17 use FluentCart\App\Services\Payments\PaymentInstance;
18 use FluentCart\App\Vite;
19 use FluentCart\Framework\Support\Arr;
20
21 class PayPal extends AbstractPaymentGateway
22 {
23
24 private $methodSlug = 'paypal';
25
26 public array $supportedFeatures = ['payment', 'refund', 'webhook', 'custom_payment', 'card_update', 'switch_payment_method' => [
27 'supported_gateways' => ['stripe', 'paypal'],
28 ], 'dispute_handler', 'subscriptions', 'resume_subscription', 'system_subscription', 'manual_subscription'];
29
30 private $vaultUserIdToken = '';
31
32 private $vaultSetupUnavailable = false;
33
34
35 public function __construct()
36 {
37 parent::__construct(
38 new PayPalSettingsBase(),
39 new PayPalSubscriptions()
40 );
41
42 add_filter('fluent_cart/payment_methods_with_custom_checkout_buttons', function ($methods) {
43 $methods[] = 'paypal';
44 return $methods;
45 });
46 }
47
48 public function meta(): array
49 {
50 return [
51 'title' => 'PayPal',
52 'route' => 'paypal',
53 'slug' => 'paypal',
54 'label' => 'PayPal',
55 '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'),
56 'logo' => Vite::getAssetUrl("images/payment-methods/paypal-icon.svg"),
57 'icon' => Vite::getAssetUrl("images/payment-methods/paypal-icon.svg"),
58 'brand_color' => '#60cdff',
59 'status' => $this->settings->get('is_active') === 'yes',
60 'upcoming' => false,
61 'supported_features' => $this->supportedFeatures
62 ];
63 }
64
65 public function boot()
66 {
67 (new IPN())->init();
68
69 add_action('wp_ajax_nopriv_fluent_cart_confirm_paypal_payment', [$this, 'confirmPayPalSinglePayment']);
70 add_action('wp_ajax_fluent_cart_confirm_paypal_payment', [$this, 'confirmPayPalSinglePayment']);
71
72 add_action('wp_ajax_nopriv_fluent_cart_confirm_paypal_subscription', [$this, 'confirmPayPalSubscription']);
73 add_action('wp_ajax_fluent_cart_confirm_paypal_subscription', [$this, 'confirmPayPalSubscription']);
74
75 add_action('wp_ajax_nopriv_fluent_cart_confirm_paypal_vault_setup', [$this, 'confirmPayPalVaultSetup']);
76 add_action('wp_ajax_fluent_cart_confirm_paypal_vault_setup', [$this, 'confirmPayPalVaultSetup']);
77
78 add_filter('fluent_cart/payment_methods/paypal_client_id', [$this, 'getClientId'], 10, 2);
79
80 // add PayPal partner tags
81 add_filter('script_loader_tag', function ($tag, $handle) {
82 if ($handle === 'fluent-cart-checkout-sdk-paypal') {
83 $tag = str_replace(
84 '<script ',
85 '<script data-partner-attribution-id="FLUENTCART_SP_PPCP" ', $tag
86 );
87
88 // The vault setup-token (save-without-purchase) buttons flow
89 // requires a browser-safe id token on the SDK script tag.
90 if ($this->vaultUserIdToken) {
91 $tag = str_replace(
92 '<script ',
93 '<script data-user-id-token="' . esc_attr($this->vaultUserIdToken) . '" ', $tag
94 );
95 }
96 }
97 return $tag;
98 }, 1, 2);
99
100 }
101
102 public function makePaymentFromPaymentInstance(PaymentInstance $paymentInstance)
103 {
104 if ($paymentInstance->subscription) {
105 $subscription = $paymentInstance->subscription;
106
107 // Store-managed mode: charge the first order / renewal invoice one-time.
108 // No PayPal billing agreement, no manual→automatic conversion — the
109 // invoice engine owns all future renewals.
110 if ($this->shouldChargeSubscriptionAsOneTime($paymentInstance)) {
111 $paymentArgs = [];
112
113 // System subscriptions vault the buyer's PayPal account during this
114 // purchase (save-on-success) so future renewal invoices can be
115 // charged merchant-initiated. The disclosure is shown at checkout
116 // and PayPal's own approval UI carries the save agreement.
117 if ($subscription->collection_method === 'system') {
118 // Nothing payable now (free trial): a $0 PayPal order is invalid —
119 // vault via a Vault v3 setup token instead (no purchase).
120 if ((int) $paymentInstance->transaction->total <= 0) {
121 return (new Processor())->handleSetupOnlyPayment($paymentInstance);
122 }
123
124 $paymentArgs['vault_on_success'] = true;
125 }
126
127 return (new Processor())->handleSinglePayment($paymentInstance, $paymentArgs);
128 }
129
130 if ($subscription->collection_method === 'manual') {
131 $previousPaymentMethod = $subscription->current_payment_method;
132 $conversionResult = $this->convertManualSubscription($subscription);
133 if (is_wp_error($conversionResult)) {
134 return $conversionResult;
135 }
136
137 $result = (new Processor())->handleSubscriptionPaymentFromPaymentInstance($paymentInstance, []);
138
139 if (is_wp_error($result)) {
140 $subscription->update([
141 'collection_method' => 'manual',
142 'current_payment_method' => $previousPaymentMethod,
143 ]);
144 } else {
145 $subscription->addLog(
146 'Converted to automatic billing',
147 sprintf('Subscription converted from manual to automatic billing via %s', 'PayPal'),
148 'info'
149 );
150 do_action('fluent_cart/subscription_converted_to_automatic', [
151 'subscription' => $subscription,
152 'payment_method' => 'paypal',
153 ]);
154 }
155
156 return $result;
157 }
158
159 return (new Processor())->handleSubscriptionPaymentFromPaymentInstance($paymentInstance, []);
160 }
161
162 return (new Processor())->handleSinglePayment($paymentInstance, []);
163 }
164
165 public function convertManualSubscription($subscription)
166 {
167 if (!$subscription || $subscription->collection_method !== 'manual') {
168 return new \WP_Error('invalid_subscription', __('Subscription is not manual or does not exist', 'fluent-cart'));
169 }
170
171 if (in_array($subscription->status, ['completed'])) {
172 return new \WP_Error('subscription_invalid_status', __('Cannot convert completed subscriptions', 'fluent-cart'));
173 }
174
175 $subscription->collection_method = 'automatic';
176 $subscription->current_payment_method = 'paypal';
177 $subscription->save();
178
179 return true;
180 }
181
182 private function shouldRenderAsSubscriptionMode($hasSubscription): bool
183 {
184 // One-time-charged subscription payments (store-managed mode, or a renewal of
185 // a store-managed-born subscription) go through handleSinglePayment, so the
186 // PayPal SDK must load with intent=capture (no vault) and getOrderInfo must
187 // report payment mode, not subscription mode.
188 if (\FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutChargesOneTime()) {
189 return false;
190 }
191
192 return $hasSubscription;
193 }
194
195 /**
196 * PayPal can vault a wallet without charging (Vault v3 setup tokens) — but
197 * only the smart-buttons flow implements it; other checkout modes keep the
198 * pre-feature behavior (gateway hidden for zero-payable system carts).
199 */
200 public function supportsSetupWithoutCharge(): bool
201 {
202 return $this->settings->get('checkout_mode') === 'paypal_pro';
203 }
204
205 /**
206 * Zero-payable system checkout on this page load: the SDK must carry a
207 * user id token and getOrderInfo must report setup mode.
208 */
209 private function isZeroPayableSetupCheckout($hasSubscription): bool
210 {
211 if (!$hasSubscription || $this->shouldRenderAsSubscriptionMode($hasSubscription)) {
212 return false;
213 }
214
215 if (!\FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutIsSystem($this)) {
216 return false;
217 }
218
219 return CartHelper::getCart() && $this->getPayableNowTotal() <= 0;
220 }
221
222 /**
223 * Amount payable on THIS checkout (items + shipping + additive taxes) — the
224 * same total the charge transaction is created with. Every frontend
225 * zero-payable decision must predict transaction->total with this computation.
226 */
227 private function getPayableNowTotal(): int
228 {
229 $checkOutHelper = CartCheckoutHelper::make();
230 $shippingChargeData = (new WebCheckoutHandler())->getShippingChargeData(CartHelper::getCart());
231 $shippingCharge = Arr::get($shippingChargeData, 'charge');
232 $totalPrice = $checkOutHelper->getItemsAmountTotal(false) + $shippingCharge;
233
234 $tax = $checkOutHelper->getCart()->checkout_data['tax_data'] ?? [];
235 $taxBehavior = (int) Arr::get($tax, 'tax_behavior', 0);
236 $storeTaxBehavior = (int) Arr::get($tax, 'store_tax_behavior', $taxBehavior);
237
238 if ($taxBehavior === 1) {
239 // Pure exclusive — add all tax including fee tax (tax_total contains both).
240 $totalPrice = $totalPrice + (int) Arr::get($tax, 'tax_total', 0)
241 + (int) Arr::get($tax, 'shipping_tax', 0);
242 } elseif ($taxBehavior === 3) {
243 // Mixed — add only exclusive product tax + fee/shipping if store is exclusive.
244 $totalPrice = $totalPrice + (int) Arr::get($tax, 'exclusive_tax_total', 0);
245 if ($storeTaxBehavior === 1) {
246 $totalPrice = $totalPrice + (int) Arr::get($tax, 'fee_tax', 0)
247 + (int) Arr::get($tax, 'shipping_tax', 0);
248 }
249 }
250
251 return (int) $totalPrice;
252 }
253
254 /**
255 * Off-session charge of a system subscription's renewal invoice against the
256 * vaulted PayPal token. Contract per
257 * dev-docs/system-subscriptions/gateway-implementation-guide.md.
258 *
259 * @param PaymentInstance $paymentInstance
260 * @param array $args ['attempt' => int]
261 * @return true|string|\WP_Error true = confirmed; 'processing' = accepted,
262 * settling (webhook/reconciler will confirm)
263 */
264 public function chargeRenewal(PaymentInstance $paymentInstance, $args = [])
265 {
266 return (new Processor())->chargeVaultedRenewal($paymentInstance, $args);
267 }
268
269 /**
270 * Re-check a processing vault charge (lost webhook / slow eCheck).
271 *
272 * @param PaymentInstance $paymentInstance
273 * @return true|string|\WP_Error
274 */
275 public function reconcileRenewalCharge(PaymentInstance $paymentInstance)
276 {
277 return (new Processor())->reconcileVaultedRenewal($paymentInstance);
278 }
279
280 public function syncRemoteTransaction(\FluentCart\App\Models\OrderTransaction $transaction)
281 {
282 return (new Processor())->syncRemoteTransaction($transaction);
283 }
284
285 public function confirmPayPalSinglePayment()
286 {
287 if (empty(App::request()->get('payId')) || empty(App::request()->get('ref_id'))) {
288 wp_send_json([
289 'status' => 'failed',
290 'message' => __('No payId ID!', 'fluent-cart')
291 ], 422);
292 }
293
294 $payPalReferenceId = sanitize_text_field(App::request()->get('payId'));
295 $transactionHash = sanitize_text_field(App::request()->get('ref_id'));
296
297 $payment_intent = $this->verifyPayPalPayment($payPalReferenceId);
298
299 if (is_wp_error($payment_intent)) {
300 wp_send_json([
301 'status' => 'failed',
302 'message' => $payment_intent->get_error_message(),
303 ], 422);
304 }
305
306 $transaction = null;
307
308 $intendedTransactionHash = Arr::get($payment_intent, 'purchase_units.0.reference_id', '');
309 if ($intendedTransactionHash) {
310 $transaction = OrderTransaction::query()
311 ->where('uuid', $intendedTransactionHash)
312 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
313 ->first();
314 }
315
316 if (!$transaction) {
317 $transaction = OrderTransaction::query()
318 ->where('uuid', $transactionHash)
319 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
320 ->first();
321 }
322
323 if (!$transaction) {
324 wp_send_json([
325 'status' => 'failed',
326 'message' => __('Transaction not found!', 'fluent-cart')
327 ], 423);
328 }
329
330 // Bind the PayPal payment to THIS transaction. FluentCart sets the
331 // transaction uuid as the PayPal order reference_id/custom_id at creation,
332 // so a legitimate confirmation always references it. Requiring the match
333 // prevents a real payment for one order from being applied to an unrelated
334 // order via a forged ref_id in the fallback above.
335 $referencedHashes = [];
336 foreach (Arr::get($payment_intent, 'purchase_units', []) as $unit) {
337 $referencedHashes[] = Arr::get($unit, 'reference_id', '');
338 $referencedHashes[] = Arr::get($unit, 'custom_id', '');
339 }
340 if (!in_array($transaction->uuid, array_filter($referencedHashes), true)) {
341 wp_send_json([
342 'status' => 'failed',
343 'message' => __('Payment does not match this transaction!', 'fluent-cart')
344 ], 422);
345 }
346
347 // Move the money ourselves — never trust the browser to have captured.
348 // FluentCart creates the order with intent=CAPTURE, but the buyer only
349 // AUTHORIZES it in the popup (status APPROVED). The funds are not captured
350 // until we call capture server-side. An APPROVED-but-uncaptured order means
351 // PayPal is holding $0; accepting it as paid delivers the product for free.
352 if (Arr::get($payment_intent, 'status') === 'APPROVED') {
353 $captured = $this->capturePayPalPayment($payPalReferenceId);
354
355 if (is_wp_error($captured)) {
356 // The normal (non-malicious) flow captures in the browser first, so by
357 // the time we reach here the order may already be captured. That is
358 // success, not failure: re-read the order and continue. Any other
359 // capture error is fatal.
360 if (!$this->isAlreadyCapturedError($captured)) {
361 wp_send_json([
362 'status' => 'failed',
363 'message' => $captured->get_error_message(),
364 ], 422);
365 }
366
367 $payment_intent = $this->verifyPayPalPayment($payPalReferenceId);
368 if (is_wp_error($payment_intent)) {
369 wp_send_json([
370 'status' => 'failed',
371 'message' => $payment_intent->get_error_message(),
372 ], 422);
373 }
374 } else {
375 $payment_intent = $captured;
376 }
377 }
378
379 // Only a COMPLETED order (its capture actually moved money) counts as paid.
380 // APPROVED is deliberately NOT accepted here.
381 if (Arr::get($payment_intent, 'status') !== 'COMPLETED') {
382 wp_send_json([
383 'status' => 'failed',
384 'message' => __('Payment not completed!', 'fluent-cart')
385 ], 422);
386 }
387
388 $paidAmount = 0;
389 $paidCurrency = '';
390 foreach (Arr::get($payment_intent, 'purchase_units', []) as $unit) {
391 $paidAmount += Helper::toCent(Arr::get($unit, 'amount.value', 0));
392 if (!$paidCurrency) {
393 $paidCurrency = strtoupper(Arr::get($unit, 'amount.currency_code', ''));
394 }
395 }
396
397 if ($paidAmount != $transaction->total) {
398 fluent_cart_warning_log(
399 __('PayPal Amount Mismatch Attempt', 'fluent-cart'),
400 sprintf(
401 /* translators: %1$s: expected amount, %2$s: received amount */
402 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. This may indicate payment tampering.', 'fluent-cart'),
403 Helper::toDecimal($transaction->total),
404 Helper::toDecimal($paidAmount)
405 ),
406 [
407 'module_name' => 'order',
408 'module_id' => $transaction->order_id,
409 'log_type' => 'api'
410 ]
411 );
412 wp_send_json([
413 'status' => 'failed',
414 'message' => __('Paid amount does not match with transaction amount!', 'fluent-cart')
415 ], 422);
416 }
417
418 if ($paidCurrency && $transaction->currency && strtoupper($transaction->currency) !== $paidCurrency) {
419 fluent_cart_warning_log(
420 __('PayPal Currency Mismatch Attempt', 'fluent-cart'),
421 sprintf(
422 /* translators: %1$s: expected currency, %2$s: received currency */
423 __('Payment currency mismatch detected. Expected: %1$s, Received: %2$s. This may indicate payment tampering.', 'fluent-cart'),
424 $transaction->currency,
425 $paidCurrency
426 ),
427 [
428 'module_name' => 'order',
429 'module_id' => $transaction->order_id,
430 'log_type' => 'api'
431 ]
432 );
433 wp_send_json([
434 'status' => 'failed',
435 'message' => __('Payment currency does not match with transaction currency!', 'fluent-cart')
436 ], 422);
437 }
438
439 $capture = Arr::get($payment_intent, 'purchase_units.0.payments.captures.0', []);
440 $chargeId = Arr::get($capture, 'id', '');
441 $captureStatus = Arr::get($capture, 'status', '');
442
443 if ($captureStatus === 'PENDING') {
444 if (!$this->recordPendingCapture($transaction, $capture)) {
445 // The capture ID already belongs to another transaction. The eventual
446 // PAYMENT.CAPTURE.COMPLETED webhook resolves by vendor_charge_id and will
447 // update that other transaction, so this buyer must never be redirected
448 // to a receipt that will now stay pending forever.
449 wp_send_json([
450 'status' => 'failed',
451 'message' => __('This PayPal payment has already been processed!', 'fluent-cart')
452 ], 422);
453 }
454
455 wp_send_json([
456 'status' => 'pending',
457 'redirect_url' => $transaction->getReceiptPageUrl(true),
458 'order' => [
459 'uuid' => $transaction->order->uuid
460 ],
461 'message' => __('Your payment is being reviewed by PayPal. Your order will be confirmed once the payment is completed.', 'fluent-cart')
462 ], 202);
463 }
464
465 if (!$chargeId || $captureStatus !== 'COMPLETED') {
466 wp_send_json([
467 'status' => 'failed',
468 'message' => __('Payment not completed!', 'fluent-cart')
469 ], 422);
470 }
471
472 $duplicateCapture = false;
473
474 $payPalCaptureLockAcquired = $this->acquirePayPalCaptureLock($chargeId);
475 if (!$payPalCaptureLockAcquired) {
476 wp_send_json([
477 'status' => 'failed',
478 'message' => __('Payment confirmation is already processing. Please try again.', 'fluent-cart')
479 ], 409);
480 }
481
482 // Prevent a single PayPal capture from being applied to more than one
483 // transaction (replay/duplicate-capture protection).
484 try {
485 $duplicateCapture = $this->hasExistingPayPalCapture($transaction, $chargeId);
486
487 if (!$duplicateCapture) {
488 // All Verified! Let's update the transaction and order
489 (new Processor())->confirmPaymentSuccessByCharge($transaction, [
490 'vendor_charge_id' => $chargeId,
491 'status' => Status::TRANSACTION_SUCCEEDED,
492 'total' => $paidAmount,
493 'payment_method_type' => 'PayPal',
494 'meta' => [
495 'payer' => Arr::get($payment_intent, 'payer', [])
496 ],
497 'payment_source' => Arr::get($payment_intent, 'payment_source', []),
498 ]);
499
500 // System subscription: persist the vault token from the captured
501 // order (or demote to manual when vaulting did not happen).
502 (new Processor())->maybePersistVaultToken($transaction, $payment_intent);
503 }
504 } finally {
505 if ($payPalCaptureLockAcquired) {
506 $this->releasePayPalCaptureLock($chargeId);
507 }
508 }
509
510 if ($duplicateCapture) {
511 wp_send_json([
512 'status' => 'failed',
513 'message' => __('This PayPal payment has already been processed!', 'fluent-cart')
514 ], 422);
515 }
516
517 wp_send_json([
518 'status' => 'success',
519 'redirect_url' => $transaction->getReceiptPageUrl(true),
520 'order' => [
521 'uuid' => $transaction->order->uuid
522 ],
523 'message' => __('Payment has been paid successfully! Redirecting...', 'fluent-cart')
524 ]);
525 }
526
527 /**
528 * AJAX confirmation of a zero-payable system checkout: the buyer approved
529 * the vault setup token in PayPal's popup; exchange it for a durable payment
530 * token, persist it on the subscription, and complete the $0 order.
531 */
532 public function confirmPayPalVaultSetup()
533 {
534 $setupTokenId = sanitize_text_field(App::request()->get('setup_token', ''));
535 $transactionHash = sanitize_text_field(App::request()->get('ref_id', ''));
536
537 if (!$setupTokenId || !$transactionHash) {
538 wp_send_json([
539 'status' => 'failed',
540 'message' => __('No setup token!', 'fluent-cart')
541 ], 422);
542 }
543
544 $transaction = OrderTransaction::query()
545 ->where('uuid', $transactionHash)
546 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
547 ->first();
548
549 if (!$transaction) {
550 wp_send_json([
551 'status' => 'failed',
552 'message' => __('Transaction not found!', 'fluent-cart')
553 ], 423);
554 }
555
556 // Bind the approval to THIS transaction — the setup token id was stored
557 // on it at creation, so a forged ref_id/token pair can never match.
558 if (Arr::get($transaction->meta ?? [], 'paypal_setup_token_id') !== $setupTokenId) {
559 wp_send_json([
560 'status' => 'failed',
561 'message' => __('Setup token does not match this transaction!', 'fluent-cart')
562 ], 422);
563 }
564
565 // Locked on the transaction uuid, not the token — a resubmission mints a
566 // new token, and a token-keyed lock would not serialize the two. The
567 // binding write in handleSetupOnlyPayment takes the same lock.
568 $payPalVaultLockAcquired = Processor::acquireVaultTransactionLock($transactionHash);
569 if (!$payPalVaultLockAcquired) {
570 wp_send_json([
571 'status' => 'failed',
572 'message' => __('Payment confirmation is already processing. Please try again.', 'fluent-cart')
573 ], 409);
574 }
575
576 $result = true;
577
578 try {
579 /** @var OrderTransaction $transaction */
580 $transaction = OrderTransaction::query()->find($transaction->id);
581
582 // Re-check the binding under the lock — the setup token may have
583 // been replaced since the pre-lock check, making this approval stale.
584 if (Arr::get($transaction->meta ?? [], 'paypal_setup_token_id') !== $setupTokenId) {
585 $result = new \WP_Error('stale_setup_token', __('This PayPal approval is no longer valid. Please try again.', 'fluent-cart'));
586 } else {
587 $result = (new Processor())->confirmVaultSetup($transaction, $setupTokenId);
588 }
589 } finally {
590 if ($payPalVaultLockAcquired) {
591 Processor::releaseVaultTransactionLock($transactionHash);
592 }
593 }
594
595 if (is_wp_error($result)) {
596 wp_send_json([
597 'status' => 'failed',
598 'message' => $result->get_error_message()
599 ], 422);
600 }
601
602 wp_send_json([
603 'status' => 'success',
604 'redirect_url' => $transaction->getReceiptPageUrl(true),
605 'order' => [
606 'uuid' => $transaction->order->uuid
607 ],
608 'message' => __('Your PayPal account has been saved successfully! Redirecting...', 'fluent-cart')
609 ]);
610 }
611
612 public function confirmPayPalSubscription()
613 {
614 if (empty(App::request()->get('subscription_id')) || empty(App::request()->get('ref_id'))) {
615 wp_send_json([
616 'status' => 'failed',
617 'message' => __('No Subscription ID!', 'fluent-cart')
618 ], 423);
619 }
620
621 $subscriptionId = sanitize_text_field(App::request()->get('subscription_id'));
622
623 $paypalSubscription = $this->getPayPalSubscription($subscriptionId);
624
625 if (is_wp_error($paypalSubscription)) {
626 wp_send_json([
627 'message' => $paypalSubscription->get_error_message(),
628 'status' => 'failed',
629 ], 422);
630 }
631
632
633 $status = Arr::get($paypalSubscription, 'status', '');
634
635 if ($status != 'ACTIVE') {
636 wp_send_json([
637 'status' => 'failed',
638 'message' => __('Subscription is not active', 'fluent-cart')
639 ], 423);
640 }
641
642 $transaction = OrderTransaction::query()->where('uuid', sanitize_text_field(App::request()->get('ref_id')))->first();
643
644 if (!$transaction) {
645 wp_send_json([
646 'status' => 'failed',
647 'message' => __('Transaction not found!', 'fluent-cart')
648 ], 404);
649 }
650
651 $localSubscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
652
653 if (!$localSubscription) {
654 wp_send_json([
655 'status' => 'failed',
656 'message' => __('Subscription not found!', 'fluent-cart')
657 ], 404);
658 }
659
660 // Bind the PayPal subscription to THIS local subscription. FluentCart sets
661 // the local subscription uuid as the PayPal subscription custom_id at
662 // creation (the same field the IPN webhook resolves by), so a forged ref_id
663 // cannot point an unrelated active PayPal subscription at another customer's
664 // transaction.
665 $paypalCustomId = Arr::get($paypalSubscription, 'custom_id', '');
666 if ($paypalCustomId !== $localSubscription->uuid) {
667 wp_send_json([
668 'status' => 'failed',
669 'message' => __('PayPal subscription does not match this transaction!', 'fluent-cart')
670 ], 422);
671 }
672
673 // Prevent the same PayPal subscription from being bound to more than one
674 // local subscription (reuse protection).
675 $alreadyUsed = Subscription::query()
676 ->where('vendor_subscription_id', $subscriptionId)
677 ->where('id', '!=', $localSubscription->id)
678 ->first();
679 if ($alreadyUsed) {
680 wp_send_json([
681 'status' => 'failed',
682 'message' => __('This PayPal subscription has already been used!', 'fluent-cart')
683 ], 422);
684 }
685
686 // Verify the PayPal subscription's plan matches the expected plan
687 if ($localSubscription && $localSubscription->vendor_plan_id) {
688 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
689 if ($paypalPlanId && $paypalPlanId !== $localSubscription->vendor_plan_id) {
690 fluent_cart_add_log(
691 'PayPal Subscription Plan Mismatch',
692 'The PayPal subscription plan ID does not match the expected plan ID for this subscription. This may indicate a configuration issue or potential tampering.',
693 [
694 'module_name' => 'subscription',
695 'module_id' => $localSubscription->id,
696 'log_type' => 'api'
697 ]
698 );
699
700 wp_send_json([
701 'status' => 'failed',
702 'message' => __('PayPal subscription plan does not match the expected plan.', 'fluent-cart')
703 ], 422);
704 }
705 }
706
707 $subscriptionModel = (new Processor())->activateSubscription($paypalSubscription, $transaction);
708
709 if (!$subscriptionModel || !in_array($subscriptionModel->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING], true)) {
710 wp_send_json([
711 'status' => 'failed',
712 'message' => __('Subscription activation failed.', 'fluent-cart')
713 ], 422);
714 }
715
716 wp_send_json([
717 'status' => 'success',
718 'message' => __('Subscription has been activated successfully!', 'fluent-cart'),
719 'redirect_url' => $transaction->getReceiptPageUrl(true),
720 'order' => [
721 'uuid' => $transaction->order->uuid
722 ],
723 ], 200);
724 }
725
726 protected function getPayPalSubscription($subscriptionId)
727 {
728 return API::getResource('billing/subscriptions/' . $subscriptionId);
729 }
730
731 protected function verifyPayPalPayment($payPalReferenceId)
732 {
733 return API::verifyPayment($payPalReferenceId);
734 }
735
736 protected function capturePayPalPayment($payPalReferenceId)
737 {
738 return API::captureOrder($payPalReferenceId);
739 }
740
741 /**
742 * Detects PayPal's "this order was already captured" response. In the normal flow the
743 * browser captures first, so our server-side capture of the same order legitimately
744 * fails with 422 UNPROCESSABLE_ENTITY / issue ORDER_ALREADY_CAPTURED — that is expected
745 * and must be treated as success (re-GET the order), not as a payment failure.
746 *
747 * @param \WP_Error $error
748 * @return bool
749 */
750 protected function isAlreadyCapturedError($error)
751 {
752 if ($error->get_error_code() === 'ORDER_ALREADY_CAPTURED') {
753 return true;
754 }
755
756 $body = $error->get_error_data();
757 if (is_array($body)) {
758 $issue = Arr::get($body, 'details.0.issue', '');
759 if ($issue === 'ORDER_ALREADY_CAPTURED') {
760 return true;
761 }
762 }
763
764 return false;
765 }
766
767 /**
768 * A PENDING capture has moved no money. Bind its id to the transaction so the
769 * PAYMENT.CAPTURE.COMPLETED webhook resolves it without the order-lookup
770 * fallback, and record PayPal's hold reason (ECHECK, PENDING_REVIEW,
771 * RECEIVING_PREFERENCE_MANDATES_MANUAL_ACTION, ...) on the order for support.
772 *
773 * Shares the completed-path capture lock so a concurrent confirmation for the
774 * same charge id cannot bind it to two transactions. Returns false when the
775 * charge id already belongs to another transaction — the caller must not treat
776 * that as pending-for-this-order.
777 *
778 * @param OrderTransaction $transaction
779 * @param array $capture
780 * @return bool
781 */
782 protected function recordPendingCapture(OrderTransaction $transaction, $capture)
783 {
784 $chargeId = Arr::get($capture, 'id', '');
785
786 if (!$chargeId) {
787 return true;
788 }
789
790 if ($transaction->vendor_charge_id === $chargeId) {
791 return true;
792 }
793
794 $payPalCaptureLockAcquired = $this->acquirePayPalCaptureLock($chargeId);
795 if (!$payPalCaptureLockAcquired) {
796 return false;
797 }
798
799 try {
800 if ($this->hasExistingPayPalCapture($transaction, $chargeId)) {
801 return false;
802 }
803
804 if (!$transaction->vendor_charge_id) {
805 $transaction->update([
806 'vendor_charge_id' => $chargeId,
807 'payment_method' => 'paypal',
808 ]);
809 }
810 } finally {
811 $this->releasePayPalCaptureLock($chargeId);
812 }
813
814 $reason = Arr::get($capture, 'status_details.reason', '');
815
816 fluent_cart_add_log(
817 __('PayPal Payment Pending', 'fluent-cart'),
818 sprintf(
819 /* translators: %1$s: PayPal capture id, %2$s: PayPal hold reason */
820 __('PayPal placed this payment on hold and no money has moved yet. Capture: %1$s, Reason: %2$s. The order stays unpaid until the PAYMENT.CAPTURE.COMPLETED webhook arrives.', 'fluent-cart'),
821 $chargeId ? $chargeId : 'unknown',
822 $reason ? $reason : 'unknown'
823 ),
824 'info',
825 [
826 'module_name' => 'order',
827 'module_id' => $transaction->order_id,
828 'log_type' => 'api'
829 ]
830 );
831
832 return true;
833 }
834
835 protected function hasExistingPayPalCapture(OrderTransaction $transaction, $chargeId)
836 {
837 return (bool) OrderTransaction::query()
838 ->where('vendor_charge_id', $chargeId)
839 ->where('id', '!=', $transaction->id)
840 ->first();
841 }
842
843 protected function acquirePayPalCaptureLock($chargeId)
844 {
845 global $wpdb;
846
847 $result = $wpdb->get_var($wpdb->prepare(
848 'SELECT GET_LOCK(%s, %d)',
849 $this->getPayPalCaptureLockName($chargeId),
850 10
851 ));
852
853 return (string) $result === '1';
854 }
855
856 protected function releasePayPalCaptureLock($chargeId)
857 {
858 global $wpdb;
859
860 $wpdb->get_var($wpdb->prepare(
861 'SELECT RELEASE_LOCK(%s)',
862 $this->getPayPalCaptureLockName($chargeId)
863 ));
864 }
865
866 protected function getPayPalCaptureLockName($chargeId)
867 {
868 return 'fluent_cart_paypal_capture_' . md5($chargeId);
869 }
870
871 public function getClientId($value, $args)
872 {
873 return $this->settings->getPublicKey();
874 }
875
876 public function handleIPN()
877 {
878 if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] != 'POST') {
879 return;
880 }
881
882 (new IPN())->processWebhook();
883 exit(200);
884 }
885
886 public function getTransactionUrl($url, $data)
887 {
888 if (Arr::get($data, 'payment_mode') === 'test') {
889 return 'https://www.sandbox.paypal.com/activity/payment/' . Arr::get($data, 'vendor_charge_id');
890 }
891
892 return 'https://www.paypal.com/activity/payment/' . Arr::get($data, 'vendor_charge_id');
893 }
894
895 public function appAuthenticator($request)
896 {
897 ConnectConfig::parseConnectInfos($request);
898 }
899
900 public function getSubscriptionUrl($url, $data)
901 {
902 if (Arr::get($data, 'payment_mode') === 'test') {
903 return 'https://www.sandbox.paypal.com/billing/subscriptions/' . Arr::get($data, 'vendor_subscription_id');
904 }
905
906 return 'https://www.paypal.com/billing/subscriptions' . Arr::get($data, 'vendor_subscription_id');
907 }
908
909 public static function beforeSettingsUpdate($data, $oldSettings): array
910 {
911 if (Arr::get($data, 'payment_mode') === 'live') {
912 $data['live_client_secret'] = Helper::encryptKey($data['live_client_secret']);
913 } else {
914 $data['test_client_secret'] = Helper::encryptKey($data['test_client_secret']);
915 }
916
917 if (isset($data['define_test_keys'])) {
918 unset($data['define_test_keys']);
919 }
920 if (isset($data['define_live_keys'])) {
921 unset($data['define_live_keys']);
922 }
923 //clean existing access token if exist, fix for: api key change authentication error
924 fluent_cart_update_option('_paypal_access_token_' . Arr::get($data, 'payment_mode'), []);
925
926 return $data;
927 }
928
929 public function isEnabled(): bool
930 {
931 return $this->settings->isActive();
932 }
933
934 /**
935 * Connect configuration should return
936 */
937 public function getConnectInfo()
938 {
939 return ConnectConfig::getConnectConfig();
940 }
941
942 public function disconnect($data)
943 {
944 return ConnectConfig::disconnect($data);
945 }
946
947 public function getWebhookInfo($mode = 'test')
948 {
949 $webhookId = $this->settings->get($mode . '_webhook_id');
950 $webhookEvents = $this->settings->get($mode . '_webhook_events');
951
952 if (!$webhookId || !$webhookEvents) {
953 return false;
954 }
955
956 /**
957 * return string
958 * webhook url also in code formatted and add copy button
959 * webhook id
960 * webhook events (list of events, and every list item should be code formatted), if not empty
961 * $webhookUrl = home_url('/wp-json/fluent-cart/v2/webhook?fct_payment_listener=1&method=paypal')
962 */
963
964 $webhookInfo = '';
965 if ($webhookId) {
966 $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>';
967 }
968 if ($webhookEvents) {
969 $webhookInfo .= '<p>' . __('and now watching for Webhook Events listed bellow:', 'fluent-cart') . '</p><p style="word-wrap: break-word;
970 font-size: 12px;" class="copyable-content">';
971 foreach ($webhookEvents as $event) {
972 $webhookInfo .= $event['name'] . ' | ';
973 }
974 $webhookInfo .= '</p>';
975 }
976
977 return $webhookInfo;
978 }
979
980 public function fields()
981 {
982 $testSchema = [
983 'webhook_instruction' => [
984 'value' => Webhook::webhookInstruction(),
985 'label' => __('Webhook Setup', 'fluent-cart'),
986 'type' => 'html_attr'
987 ],
988 'test_webhook_id' => [
989 'value' => '',
990 'placeholder' => 'Webhook ID',
991 'required' => true,
992 'label' => __('Test Webhook ID (Copy the webhook id and paste bellow)', 'fluent-cart'),
993 'type' => 'text'
994 ],
995 ];
996
997 $liveSchema = [
998 'webhook_instruction' => [
999 'value' => Webhook::webhookInstruction(),
1000 'label' => __('Webhook Setup', 'fluent-cart'),
1001 'type' => 'html_attr'
1002 ],
1003 'live_webhook_id' => [
1004 'value' => '',
1005 'placeholder' => 'Webhook ID',
1006 'required' => true,
1007 'label' => __('Live Webhook ID (Copy the webhook id and paste bellow)', 'fluent-cart'),
1008 'type' => 'text'
1009 ],
1010 ];
1011
1012 // if not defined property then no need to show webhook instruction
1013 if ($this->settings->getProviderType() !== 'api_keys') {
1014 $testSchema = [];
1015 $liveSchema = [];
1016 }
1017
1018 $payPalFields = array(
1019 'notice' => [
1020 'value' => $this->renderStoreModeNotice(),
1021 'label' => __('PayPal', 'fluent-cart'),
1022 'type' => 'notice'
1023 ],
1024 'payment_mode' => [
1025 'type' => 'tabs',
1026 'schema' => [
1027 [
1028 'type' => 'tab',
1029 'label' => __('Live credentials', 'fluent-cart'),
1030 'value' => 'live',
1031 'schema' => $liveSchema
1032 ],
1033 [
1034 'type' => 'tab',
1035 'label' => __('Test credentials', 'fluent-cart'),
1036 'value' => 'test',
1037 'schema' => $testSchema
1038 ]
1039 ]
1040 ],
1041 'provider' => array(
1042 'value' => $this->settings->getProviderType(),
1043 'label' => __('Provider', 'fluent-cart'),
1044 'type' => 'provider'
1045 ),
1046 'webhook_info_test' => array(
1047 'info' => $this->getWebhookInfo('test'),
1048 'label' => __('Webhook Info', 'fluent-cart'),
1049 'type' => 'webhook_info',
1050 'mode' => 'test'
1051 ),
1052 'webhook_info_live' => array(
1053 'info' => $this->getWebhookInfo('live'),
1054 'label' => __('Webhook Info', 'fluent-cart'),
1055 'type' => 'webhook_info',
1056 'mode' => 'live'
1057 ),
1058 'is_pro_item' => array(
1059 'value' => 'no',
1060 'label' => __('PayPal', 'fluent-cart'),
1061 'type' => 'validate'
1062 ),
1063 );
1064
1065 return $payPalFields;
1066 }
1067
1068 public function webHookPaymentMethodName()
1069 {
1070 return $this->methodSlug;
1071 }
1072
1073 public static function validateSettings($data): array
1074 {
1075 $mode = Arr::get($data, 'payment_mode', 'test');
1076 $provider = Arr::get($data, 'provider', 'connect');
1077
1078 if ($provider === 'api_keys') {
1079 if ($mode === 'live') {
1080 $clientId = defined('FCT_PAYPAL_LIVE_PUBLIC_KEY') ? FCT_PAYPAL_LIVE_PUBLIC_KEY : Arr::get($data, 'live_client_id');
1081 $clientSecret = defined('FCT_PAYPAL_LIVE_SECRET_KEY') ? FCT_PAYPAL_LIVE_SECRET_KEY : Arr::get($data, 'live_client_secret');
1082 } else {
1083 $clientId = defined('FCT_PAYPAL_TEST_PUBLIC_KEY') ? FCT_PAYPAL_TEST_PUBLIC_KEY : Arr::get($data, 'test_client_id');
1084 $clientSecret = defined('FCT_PAYPAL_TEST_SECRET_KEY') ? FCT_PAYPAL_TEST_SECRET_KEY : Arr::get($data, 'test_client_secret');
1085 }
1086
1087 return static::validateApiCredentials($clientId, $clientSecret, $mode);
1088
1089 }
1090
1091 $clientId = Arr::get($data, "{$mode}_client_id");
1092 $clientSecret = Arr::get($data, "{$mode}_client_secret");
1093
1094 if (!$clientId || !$clientSecret) {
1095 return [
1096 'status' => 'failed',
1097 'message' => $mode === 'live' ? __('PayPal live credentials are required!', 'fluent-cart') : __('PayPal test credentials are required!', 'fluent-cart'),
1098 ];
1099 }
1100
1101 return [
1102 'status' => 'success',
1103 'message' => __('Credentials are valid!', 'fluent-cart')
1104 ];
1105
1106 }
1107
1108 private static function validateApiCredentials($clientId, $clientSecret, $mode): array
1109 {
1110 $result = API::validateCredentials($clientId, $clientSecret, $mode);
1111
1112 if (is_wp_error($result)) {
1113 return [
1114 'status' => 'failed',
1115 'message' => $result->get_error_message()
1116 ];
1117 }
1118
1119 return [
1120 'status' => 'success',
1121 'message' => __('Credentials are valid!', 'fluent-cart')
1122 ];
1123
1124 }
1125
1126 /*
1127 * Default sdk enqueue version is the plugin version
1128 * if any sdk require a specific version, then override this method
1129 * or to remove a version, return null
1130 */
1131 public function getEnqueueVersion()
1132 {
1133 return null;
1134 }
1135
1136 public function getEnqueueScriptSrc($hasSubscription = false): array
1137 {
1138 if ($this->settings->get('checkout_mode') !== 'paypal_pro') {
1139 return [];
1140 }
1141
1142 $clientId = $this->settings->getPublicKey();
1143 $clientId = sanitize_text_field($clientId);
1144
1145 $sdkSrc = 'https://www.paypal.com/sdk/js?client-id=' . $clientId;
1146
1147 $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1148
1149 if ($renderAsSubscription) {
1150 $sdkSrc = add_query_arg(array('vault' => 'true', 'intent' => 'subscription'), $sdkSrc);
1151 } else {
1152 $sdkSrc = add_query_arg(array('currency' => strtoupper(CurrencySettings::get('currency')), 'intent' => 'capture'), $sdkSrc);
1153
1154 if ($this->isZeroPayableSetupCheckout($hasSubscription)) {
1155 $idToken = API::getUserIdToken();
1156 if (!is_wp_error($idToken) && $idToken) {
1157 $this->vaultUserIdToken = $idToken;
1158 } else {
1159 // The vault buttons cannot start without the SDK id token —
1160 // tell the checkout JS to show an error, not a dead button.
1161 $this->vaultSetupUnavailable = true;
1162 if (is_wp_error($idToken)) {
1163 fluent_cart_add_log('PayPal Vault Setup', $idToken->get_error_message(), 'error', ['log_type' => 'payment']);
1164 }
1165 }
1166 }
1167 }
1168 $sdkSrc = apply_filters('fluent_cart/payments/paypal_sdk_src', $sdkSrc, []);
1169
1170 return [
1171 [
1172 'handle' => 'fluent-cart-checkout-sdk-paypal',
1173 'src' => $sdkSrc,
1174 ],
1175 [
1176 'handle' => 'fluent-cart-checkout-handler-paypal',
1177 'src' => Vite::getEnqueuePath('public/payment-methods/paypal-checkout.js'),
1178 'deps' => ['fluent-cart-checkout-sdk-paypal']
1179 ]
1180 ];
1181 }
1182
1183 public function getLocalizeData(): array
1184 {
1185 return [
1186 'fct_paypal_data' => [
1187 'vault_setup_unavailable' => $this->vaultSetupUnavailable ? 'yes' : 'no',
1188 'translations' => [
1189 'PayPal is temporarily unavailable for this checkout. Please choose another payment method or try again later.' => __('PayPal is temporarily unavailable for this checkout. Please choose another payment method or try again later.', 'fluent-cart'),
1190 'uuid not found' => __('uuid not found', 'fluent-cart'),
1191 'Choose any option to continue' => __('Choose any option to continue', 'fluent-cart'),
1192 'An unknown error occurred' => __('An unknown error occurred', 'fluent-cart'),
1193 'An error occurred while loading PayPal.' => __('An error occurred while loading PayPal.', 'fluent-cart'),
1194 'Loading Payment Processor...' => __('Loading Payment Processor...', 'fluent-cart'),
1195 'Order creation failed' => __('Order creation failed', 'fluent-cart'),
1196 'Not proper order handler' => __('Not proper order handler', 'fluent-cart'),
1197 'No Subscription ID' => __('No Subscription ID', 'fluent-cart'),
1198 'no processing' => __('no processing', 'fluent-cart'),
1199 'not proper order handler' => __('not proper order handler', 'fluent-cart'),
1200 'Payment confirmation failed' => __('Payment confirmation failed', 'fluent-cart'),
1201 ]
1202 ]
1203 ];
1204 }
1205
1206 public function processRefund($transaction, $amount, $args)
1207 {
1208 if (!$amount) {
1209 return new \WP_Error(
1210 'fluent_cart_stripe_refund_error',
1211 __('Refund amount is required.', 'fluent-cart')
1212 );
1213 }
1214
1215 return PayPalHelper::processRemoteRefund($transaction, $amount, $args);
1216 }
1217
1218 public function getOrderInfo($data)
1219 {
1220 $checkOutHelper = CartCheckoutHelper::make();
1221 $totalPrice = $this->getPayableNowTotal();
1222
1223 $items = $checkOutHelper->getItems();
1224 $hasSubscription = $this->validateSubscriptions($items);
1225
1226 $clientId = $this->settings->getPublicKey();
1227
1228 if (empty($clientId)) {
1229 $message = __('Please provide a valid Client Id!', 'fluent-cart');
1230 fluent_cart_add_log('PayPal Credential Validation', $message, 'error', ['log_type' => 'payment']);
1231 wp_send_json([
1232 'status' => 'failed',
1233 'message' => __('No valid Client ID found!', 'fluent-cart')
1234 ], 422);
1235 }
1236
1237 $paymentArgs['public_key'] = $clientId;
1238
1239 $paymentDetails = [
1240 'mode' => 'payment',
1241 'amount' => number_format(Helper::toDecimalWithoutComma($totalPrice), 2, '.', ''),
1242 'currency' => strtoupper(CurrencySettings::get('currency')),
1243 ];
1244
1245 $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1246
1247 if ($renderAsSubscription) {
1248 $paymentDetails['mode'] = 'subscription';
1249 }
1250
1251 // System (auto-charged, store-billed) checkout: the buyer's PayPal account
1252 // is vaulted during the purchase — disclose the save-and-auto-charge next
1253 // to the PayPal button (PayPal's approval popup carries the agreement too).
1254 $systemConsent = '';
1255 if (!$renderAsSubscription && \FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutIsSystem($this)) {
1256 $systemConsent = __('Your PayPal account will be saved securely and charged automatically on each renewal date. You can cancel any time from your account.', 'fluent-cart');
1257
1258 // Nothing payable now (free trial): buttons render the vault
1259 // setup-token flow. The disclosure stays informational — PayPal's
1260 // approval popup itself carries the explicit save agreement.
1261 if ($totalPrice <= 0) {
1262 $paymentDetails['mode'] = 'setup';
1263 }
1264 }
1265
1266 $this->checkCurrencySupport();
1267
1268 wp_send_json(
1269 [
1270 'data' => [],
1271 'payment_args' => $paymentArgs,
1272 'message' => __('Order info retrieved!', 'fluent-cart'),
1273 'intent' => $paymentDetails,
1274 'system_consent' => $systemConsent,
1275 ],
1276 200
1277 );
1278
1279 }
1280
1281 public function checkCurrencySupport()
1282 {
1283 $currency = CurrencySettings::get('currency');
1284
1285 if (!in_array(strtoupper($currency), self::getPaypalSupportedCurrency())) {
1286 wp_send_json([
1287 'status' => 'failed',
1288 'message' => __('PayPal does not support the currency you are using!', 'fluent-cart')
1289 ], 422);
1290 }
1291 }
1292
1293 public function isCurrencySupported(): bool
1294 {
1295 $currency = CurrencySettings::get('currency');
1296 return in_array(strtoupper($currency), self::getPaypalSupportedCurrency());
1297 }
1298
1299 public static function getPaypalSupportedCurrency(): array
1300 {
1301 return [
1302 'USD', 'EUR', 'GBP', 'AUD', 'CAD', 'JPY', 'NZD', 'CHF', 'HKD', 'SGD', 'SEK', 'DKK', 'PLN', 'NOK', 'HUF', 'CZK', 'ILS', 'MXN', 'MYR', 'BRL', 'PHP', 'TWD', 'THB'
1303 ];
1304 }
1305
1306 public function acceptRemoteDispute($transaction, $args = [])
1307 {
1308 $disputeId = Arr::get($transaction->meta, 'dispute_id');
1309 $dispute = (new API())->getResource('customer/disputes/'. $disputeId);
1310
1311 if (!$disputeId) {
1312 $dispute = (new API())->getResource('customer/disputes/'. $disputeId);
1313
1314 if (is_wp_error($dispute) || empty($dispute['dispute_id'])) {
1315 new \WP_Error('No dispute ID found!', __('Please check PayPal if the dispute is already accepted or not!', 'fluent-cart'));
1316 }
1317
1318 $disputeId = Arr::get($dispute, 'dispute_id');
1319 }
1320
1321 $note = Arr::get($args, 'dispute_note', 'Accepted full dispute claim!');
1322
1323 $closeDispute = (new API())->createResource('customer/disputes/' . $disputeId . '/accept-claim', ['note' => $note]);
1324
1325 if (is_wp_error($closeDispute)) {
1326 return $closeDispute;
1327 }
1328
1329 return $closeDispute;
1330 }
1331
1332 }
1333