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

1,352 lines 52.8 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', 'verify_vendor_ids'];
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' => $this->getConfirmRedirectUrl($transaction),
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' => $this->getConfirmRedirectUrl($transaction),
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' => $this->getConfirmRedirectUrl($transaction),
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' => $this->getConfirmRedirectUrl($transaction),
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 /**
732 * Post-payment redirect for PayPal confirm responses. The canonical
733 * fluent_cart/payment/success_url filter fires inside getSuccessUrl();
734 * the receipt_page_url filter is bridged for existing consumers of the
735 * previous PayPal redirect and will be dropped from this path later.
736 */
737 private function getConfirmRedirectUrl($transaction)
738 {
739 $url = $transaction->getSuccessUrl();
740
741 return apply_filters_deprecated(
742 'fluent_cart/transaction/receipt_page_url',
743 [$url, ['transaction' => $transaction, 'order' => $transaction->order]],
744 '1.6.2',
745 'fluent_cart/payment/success_url',
746 'PayPal post-payment redirects now go through fluent_cart/payment/success_url. Hook that filter instead; this bridge will be removed in a future release.'
747 );
748 }
749
750 protected function verifyPayPalPayment($payPalReferenceId)
751 {
752 return API::verifyPayment($payPalReferenceId);
753 }
754
755 protected function capturePayPalPayment($payPalReferenceId)
756 {
757 return API::captureOrder($payPalReferenceId);
758 }
759
760 /**
761 * Detects PayPal's "this order was already captured" response. In the normal flow the
762 * browser captures first, so our server-side capture of the same order legitimately
763 * fails with 422 UNPROCESSABLE_ENTITY / issue ORDER_ALREADY_CAPTURED — that is expected
764 * and must be treated as success (re-GET the order), not as a payment failure.
765 *
766 * @param \WP_Error $error
767 * @return bool
768 */
769 protected function isAlreadyCapturedError($error)
770 {
771 if ($error->get_error_code() === 'ORDER_ALREADY_CAPTURED') {
772 return true;
773 }
774
775 $body = $error->get_error_data();
776 if (is_array($body)) {
777 $issue = Arr::get($body, 'details.0.issue', '');
778 if ($issue === 'ORDER_ALREADY_CAPTURED') {
779 return true;
780 }
781 }
782
783 return false;
784 }
785
786 /**
787 * A PENDING capture has moved no money. Bind its id to the transaction so the
788 * PAYMENT.CAPTURE.COMPLETED webhook resolves it without the order-lookup
789 * fallback, and record PayPal's hold reason (ECHECK, PENDING_REVIEW,
790 * RECEIVING_PREFERENCE_MANDATES_MANUAL_ACTION, ...) on the order for support.
791 *
792 * Shares the completed-path capture lock so a concurrent confirmation for the
793 * same charge id cannot bind it to two transactions. Returns false when the
794 * charge id already belongs to another transaction — the caller must not treat
795 * that as pending-for-this-order.
796 *
797 * @param OrderTransaction $transaction
798 * @param array $capture
799 * @return bool
800 */
801 protected function recordPendingCapture(OrderTransaction $transaction, $capture)
802 {
803 $chargeId = Arr::get($capture, 'id', '');
804
805 if (!$chargeId) {
806 return true;
807 }
808
809 if ($transaction->vendor_charge_id === $chargeId) {
810 return true;
811 }
812
813 $payPalCaptureLockAcquired = $this->acquirePayPalCaptureLock($chargeId);
814 if (!$payPalCaptureLockAcquired) {
815 return false;
816 }
817
818 try {
819 if ($this->hasExistingPayPalCapture($transaction, $chargeId)) {
820 return false;
821 }
822
823 if (!$transaction->vendor_charge_id) {
824 $transaction->update([
825 'vendor_charge_id' => $chargeId,
826 'payment_method' => 'paypal',
827 ]);
828 }
829 } finally {
830 $this->releasePayPalCaptureLock($chargeId);
831 }
832
833 $reason = Arr::get($capture, 'status_details.reason', '');
834
835 fluent_cart_add_log(
836 __('PayPal Payment Pending', 'fluent-cart'),
837 sprintf(
838 /* translators: %1$s: PayPal capture id, %2$s: PayPal hold reason */
839 __('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'),
840 $chargeId ? $chargeId : 'unknown',
841 $reason ? $reason : 'unknown'
842 ),
843 'info',
844 [
845 'module_name' => 'order',
846 'module_id' => $transaction->order_id,
847 'log_type' => 'api'
848 ]
849 );
850
851 return true;
852 }
853
854 protected function hasExistingPayPalCapture(OrderTransaction $transaction, $chargeId)
855 {
856 return (bool) OrderTransaction::query()
857 ->where('vendor_charge_id', $chargeId)
858 ->where('id', '!=', $transaction->id)
859 ->first();
860 }
861
862 protected function acquirePayPalCaptureLock($chargeId)
863 {
864 global $wpdb;
865
866 $result = $wpdb->get_var($wpdb->prepare(
867 'SELECT GET_LOCK(%s, %d)',
868 $this->getPayPalCaptureLockName($chargeId),
869 10
870 ));
871
872 return (string) $result === '1';
873 }
874
875 protected function releasePayPalCaptureLock($chargeId)
876 {
877 global $wpdb;
878
879 $wpdb->get_var($wpdb->prepare(
880 'SELECT RELEASE_LOCK(%s)',
881 $this->getPayPalCaptureLockName($chargeId)
882 ));
883 }
884
885 protected function getPayPalCaptureLockName($chargeId)
886 {
887 return 'fluent_cart_paypal_capture_' . md5($chargeId);
888 }
889
890 public function getClientId($value, $args)
891 {
892 return $this->settings->getPublicKey();
893 }
894
895 public function handleIPN()
896 {
897 if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] != 'POST') {
898 return;
899 }
900
901 (new IPN())->processWebhook();
902 exit(200);
903 }
904
905 public function getTransactionUrl($url, $data)
906 {
907 if (Arr::get($data, 'payment_mode') === 'test') {
908 return 'https://www.sandbox.paypal.com/activity/payment/' . Arr::get($data, 'vendor_charge_id');
909 }
910
911 return 'https://www.paypal.com/activity/payment/' . Arr::get($data, 'vendor_charge_id');
912 }
913
914 public function appAuthenticator($request)
915 {
916 ConnectConfig::parseConnectInfos($request);
917 }
918
919 public function getSubscriptionUrl($url, $data)
920 {
921 if (Arr::get($data, 'payment_mode') === 'test') {
922 return 'https://www.sandbox.paypal.com/billing/subscriptions/' . Arr::get($data, 'vendor_subscription_id');
923 }
924
925 return 'https://www.paypal.com/billing/subscriptions' . Arr::get($data, 'vendor_subscription_id');
926 }
927
928 public static function beforeSettingsUpdate($data, $oldSettings): array
929 {
930 if (Arr::get($data, 'payment_mode') === 'live') {
931 $data['live_client_secret'] = Helper::encryptKey($data['live_client_secret']);
932 } else {
933 $data['test_client_secret'] = Helper::encryptKey($data['test_client_secret']);
934 }
935
936 if (isset($data['define_test_keys'])) {
937 unset($data['define_test_keys']);
938 }
939 if (isset($data['define_live_keys'])) {
940 unset($data['define_live_keys']);
941 }
942 //clean existing access token if exist, fix for: api key change authentication error
943 fluent_cart_update_option('_paypal_access_token_' . Arr::get($data, 'payment_mode'), []);
944
945 return $data;
946 }
947
948 public function isEnabled(): bool
949 {
950 return $this->settings->isActive();
951 }
952
953 /**
954 * Connect configuration should return
955 */
956 public function getConnectInfo()
957 {
958 return ConnectConfig::getConnectConfig();
959 }
960
961 public function disconnect($data)
962 {
963 return ConnectConfig::disconnect($data);
964 }
965
966 public function getWebhookInfo($mode = 'test')
967 {
968 $webhookId = $this->settings->get($mode . '_webhook_id');
969 $webhookEvents = $this->settings->get($mode . '_webhook_events');
970
971 if (!$webhookId || !$webhookEvents) {
972 return false;
973 }
974
975 /**
976 * return string
977 * webhook url also in code formatted and add copy button
978 * webhook id
979 * webhook events (list of events, and every list item should be code formatted), if not empty
980 * $webhookUrl = home_url('/wp-json/fluent-cart/v2/webhook?fct_payment_listener=1&method=paypal')
981 */
982
983 $webhookInfo = '';
984 if ($webhookId) {
985 $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>';
986 }
987 if ($webhookEvents) {
988 $webhookInfo .= '<p>' . __('and now watching for Webhook Events listed bellow:', 'fluent-cart') . '</p><p style="word-wrap: break-word;
989 font-size: 12px;" class="copyable-content">';
990 foreach ($webhookEvents as $event) {
991 $webhookInfo .= $event['name'] . ' | ';
992 }
993 $webhookInfo .= '</p>';
994 }
995
996 return $webhookInfo;
997 }
998
999 public function fields()
1000 {
1001 $testSchema = [
1002 'webhook_instruction' => [
1003 'value' => Webhook::webhookInstruction(),
1004 'label' => __('Webhook Setup', 'fluent-cart'),
1005 'type' => 'html_attr'
1006 ],
1007 'test_webhook_id' => [
1008 'value' => '',
1009 'placeholder' => 'Webhook ID',
1010 'required' => true,
1011 'label' => __('Test Webhook ID (Copy the webhook id and paste bellow)', 'fluent-cart'),
1012 'type' => 'text'
1013 ],
1014 ];
1015
1016 $liveSchema = [
1017 'webhook_instruction' => [
1018 'value' => Webhook::webhookInstruction(),
1019 'label' => __('Webhook Setup', 'fluent-cart'),
1020 'type' => 'html_attr'
1021 ],
1022 'live_webhook_id' => [
1023 'value' => '',
1024 'placeholder' => 'Webhook ID',
1025 'required' => true,
1026 'label' => __('Live Webhook ID (Copy the webhook id and paste bellow)', 'fluent-cart'),
1027 'type' => 'text'
1028 ],
1029 ];
1030
1031 // if not defined property then no need to show webhook instruction
1032 if ($this->settings->getProviderType() !== 'api_keys') {
1033 $testSchema = [];
1034 $liveSchema = [];
1035 }
1036
1037 $payPalFields = array(
1038 'notice' => [
1039 'value' => $this->renderStoreModeNotice(),
1040 'label' => __('PayPal', 'fluent-cart'),
1041 'type' => 'notice'
1042 ],
1043 'payment_mode' => [
1044 'type' => 'tabs',
1045 'schema' => [
1046 [
1047 'type' => 'tab',
1048 'label' => __('Live credentials', 'fluent-cart'),
1049 'value' => 'live',
1050 'schema' => $liveSchema
1051 ],
1052 [
1053 'type' => 'tab',
1054 'label' => __('Test credentials', 'fluent-cart'),
1055 'value' => 'test',
1056 'schema' => $testSchema
1057 ]
1058 ]
1059 ],
1060 'provider' => array(
1061 'value' => $this->settings->getProviderType(),
1062 'label' => __('Provider', 'fluent-cart'),
1063 'type' => 'provider'
1064 ),
1065 'webhook_info_test' => array(
1066 'info' => $this->getWebhookInfo('test'),
1067 'label' => __('Webhook Info', 'fluent-cart'),
1068 'type' => 'webhook_info',
1069 'mode' => 'test'
1070 ),
1071 'webhook_info_live' => array(
1072 'info' => $this->getWebhookInfo('live'),
1073 'label' => __('Webhook Info', 'fluent-cart'),
1074 'type' => 'webhook_info',
1075 'mode' => 'live'
1076 ),
1077 'is_pro_item' => array(
1078 'value' => 'no',
1079 'label' => __('PayPal', 'fluent-cart'),
1080 'type' => 'validate'
1081 ),
1082 );
1083
1084 return $payPalFields;
1085 }
1086
1087 public function webHookPaymentMethodName()
1088 {
1089 return $this->methodSlug;
1090 }
1091
1092 public static function validateSettings($data): array
1093 {
1094 $mode = Arr::get($data, 'payment_mode', 'test');
1095 $provider = Arr::get($data, 'provider', 'connect');
1096
1097 if ($provider === 'api_keys') {
1098 if ($mode === 'live') {
1099 $clientId = defined('FCT_PAYPAL_LIVE_PUBLIC_KEY') ? FCT_PAYPAL_LIVE_PUBLIC_KEY : Arr::get($data, 'live_client_id');
1100 $clientSecret = defined('FCT_PAYPAL_LIVE_SECRET_KEY') ? FCT_PAYPAL_LIVE_SECRET_KEY : Arr::get($data, 'live_client_secret');
1101 } else {
1102 $clientId = defined('FCT_PAYPAL_TEST_PUBLIC_KEY') ? FCT_PAYPAL_TEST_PUBLIC_KEY : Arr::get($data, 'test_client_id');
1103 $clientSecret = defined('FCT_PAYPAL_TEST_SECRET_KEY') ? FCT_PAYPAL_TEST_SECRET_KEY : Arr::get($data, 'test_client_secret');
1104 }
1105
1106 return static::validateApiCredentials($clientId, $clientSecret, $mode);
1107
1108 }
1109
1110 $clientId = Arr::get($data, "{$mode}_client_id");
1111 $clientSecret = Arr::get($data, "{$mode}_client_secret");
1112
1113 if (!$clientId || !$clientSecret) {
1114 return [
1115 'status' => 'failed',
1116 'message' => $mode === 'live' ? __('PayPal live credentials are required!', 'fluent-cart') : __('PayPal test credentials are required!', 'fluent-cart'),
1117 ];
1118 }
1119
1120 return [
1121 'status' => 'success',
1122 'message' => __('Credentials are valid!', 'fluent-cart')
1123 ];
1124
1125 }
1126
1127 private static function validateApiCredentials($clientId, $clientSecret, $mode): array
1128 {
1129 $result = API::validateCredentials($clientId, $clientSecret, $mode);
1130
1131 if (is_wp_error($result)) {
1132 return [
1133 'status' => 'failed',
1134 'message' => $result->get_error_message()
1135 ];
1136 }
1137
1138 return [
1139 'status' => 'success',
1140 'message' => __('Credentials are valid!', 'fluent-cart')
1141 ];
1142
1143 }
1144
1145 /*
1146 * Default sdk enqueue version is the plugin version
1147 * if any sdk require a specific version, then override this method
1148 * or to remove a version, return null
1149 */
1150 public function getEnqueueVersion()
1151 {
1152 return null;
1153 }
1154
1155 public function getEnqueueScriptSrc($hasSubscription = false): array
1156 {
1157 if ($this->settings->get('checkout_mode') !== 'paypal_pro') {
1158 return [];
1159 }
1160
1161 $clientId = $this->settings->getPublicKey();
1162 $clientId = sanitize_text_field($clientId);
1163
1164 $sdkSrc = 'https://www.paypal.com/sdk/js?client-id=' . $clientId;
1165
1166 $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1167
1168 if ($renderAsSubscription) {
1169 $sdkSrc = add_query_arg(array('vault' => 'true', 'intent' => 'subscription'), $sdkSrc);
1170 } else {
1171 $sdkSrc = add_query_arg(array('currency' => strtoupper(CurrencySettings::get('currency')), 'intent' => 'capture'), $sdkSrc);
1172
1173 if ($this->isZeroPayableSetupCheckout($hasSubscription)) {
1174 $idToken = API::getUserIdToken();
1175 if (!is_wp_error($idToken) && $idToken) {
1176 $this->vaultUserIdToken = $idToken;
1177 } else {
1178 // The vault buttons cannot start without the SDK id token —
1179 // tell the checkout JS to show an error, not a dead button.
1180 $this->vaultSetupUnavailable = true;
1181 if (is_wp_error($idToken)) {
1182 fluent_cart_add_log('PayPal Vault Setup', $idToken->get_error_message(), 'error', ['log_type' => 'payment']);
1183 }
1184 }
1185 }
1186 }
1187 $sdkSrc = apply_filters('fluent_cart/payments/paypal_sdk_src', $sdkSrc, []);
1188
1189 return [
1190 [
1191 'handle' => 'fluent-cart-checkout-sdk-paypal',
1192 'src' => $sdkSrc,
1193 ],
1194 [
1195 'handle' => 'fluent-cart-checkout-handler-paypal',
1196 'src' => Vite::getEnqueuePath('public/payment-methods/paypal-checkout.js'),
1197 'deps' => ['fluent-cart-checkout-sdk-paypal']
1198 ]
1199 ];
1200 }
1201
1202 public function getLocalizeData(): array
1203 {
1204 return [
1205 'fct_paypal_data' => [
1206 'vault_setup_unavailable' => $this->vaultSetupUnavailable ? 'yes' : 'no',
1207 'translations' => [
1208 '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'),
1209 'uuid not found' => __('uuid not found', 'fluent-cart'),
1210 'Choose any option to continue' => __('Choose any option to continue', 'fluent-cart'),
1211 'An unknown error occurred' => __('An unknown error occurred', 'fluent-cart'),
1212 'An error occurred while loading PayPal.' => __('An error occurred while loading PayPal.', 'fluent-cart'),
1213 'Loading Payment Processor...' => __('Loading Payment Processor...', 'fluent-cart'),
1214 'Order creation failed' => __('Order creation failed', 'fluent-cart'),
1215 'Not proper order handler' => __('Not proper order handler', 'fluent-cart'),
1216 'No Subscription ID' => __('No Subscription ID', 'fluent-cart'),
1217 'no processing' => __('no processing', 'fluent-cart'),
1218 'not proper order handler' => __('not proper order handler', 'fluent-cart'),
1219 'Payment confirmation failed' => __('Payment confirmation failed', 'fluent-cart'),
1220 ]
1221 ]
1222 ];
1223 }
1224
1225 public function processRefund($transaction, $amount, $args)
1226 {
1227 if (!$amount) {
1228 return new \WP_Error(
1229 'fluent_cart_stripe_refund_error',
1230 __('Refund amount is required.', 'fluent-cart')
1231 );
1232 }
1233
1234 return PayPalHelper::processRemoteRefund($transaction, $amount, $args);
1235 }
1236
1237 public function getOrderInfo($data)
1238 {
1239 $checkOutHelper = CartCheckoutHelper::make();
1240 $totalPrice = $this->getPayableNowTotal();
1241
1242 $items = $checkOutHelper->getItems();
1243 $hasSubscription = $this->validateSubscriptions($items);
1244
1245 $clientId = $this->settings->getPublicKey();
1246
1247 if (empty($clientId)) {
1248 $message = __('Please provide a valid Client Id!', 'fluent-cart');
1249 fluent_cart_add_log('PayPal Credential Validation', $message, 'error', ['log_type' => 'payment']);
1250 wp_send_json([
1251 'status' => 'failed',
1252 'message' => __('No valid Client ID found!', 'fluent-cart')
1253 ], 422);
1254 }
1255
1256 $paymentArgs['public_key'] = $clientId;
1257
1258 $paymentDetails = [
1259 'mode' => 'payment',
1260 'amount' => number_format(Helper::toDecimalWithoutComma($totalPrice), 2, '.', ''),
1261 'currency' => strtoupper(CurrencySettings::get('currency')),
1262 ];
1263
1264 $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1265
1266 if ($renderAsSubscription) {
1267 $paymentDetails['mode'] = 'subscription';
1268 }
1269
1270 // System (auto-charged, store-billed) checkout: the buyer's PayPal account
1271 // is vaulted during the purchase — disclose the save-and-auto-charge next
1272 // to the PayPal button (PayPal's approval popup carries the agreement too).
1273 $systemConsent = '';
1274 if (!$renderAsSubscription && \FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutIsSystem($this)) {
1275 $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');
1276
1277 // Nothing payable now (free trial): buttons render the vault
1278 // setup-token flow. The disclosure stays informational — PayPal's
1279 // approval popup itself carries the explicit save agreement.
1280 if ($totalPrice <= 0) {
1281 $paymentDetails['mode'] = 'setup';
1282 }
1283 }
1284
1285 $this->checkCurrencySupport();
1286
1287 wp_send_json(
1288 [
1289 'data' => [],
1290 'payment_args' => $paymentArgs,
1291 'message' => __('Order info retrieved!', 'fluent-cart'),
1292 'intent' => $paymentDetails,
1293 'system_consent' => $systemConsent,
1294 ],
1295 200
1296 );
1297
1298 }
1299
1300 public function checkCurrencySupport()
1301 {
1302 $currency = CurrencySettings::get('currency');
1303
1304 if (!in_array(strtoupper($currency), self::getPaypalSupportedCurrency())) {
1305 wp_send_json([
1306 'status' => 'failed',
1307 'message' => __('PayPal does not support the currency you are using!', 'fluent-cart')
1308 ], 422);
1309 }
1310 }
1311
1312 public function isCurrencySupported(): bool
1313 {
1314 $currency = CurrencySettings::get('currency');
1315 return in_array(strtoupper($currency), self::getPaypalSupportedCurrency());
1316 }
1317
1318 public static function getPaypalSupportedCurrency(): array
1319 {
1320 return [
1321 'USD', 'EUR', 'GBP', 'AUD', 'CAD', 'JPY', 'NZD', 'CHF', 'HKD', 'SGD', 'SEK', 'DKK', 'PLN', 'NOK', 'HUF', 'CZK', 'ILS', 'MXN', 'MYR', 'BRL', 'PHP', 'TWD', 'THB'
1322 ];
1323 }
1324
1325 public function acceptRemoteDispute($transaction, $args = [])
1326 {
1327 $disputeId = Arr::get($transaction->meta, 'dispute_id');
1328 $dispute = (new API())->getResource('customer/disputes/'. $disputeId);
1329
1330 if (!$disputeId) {
1331 $dispute = (new API())->getResource('customer/disputes/'. $disputeId);
1332
1333 if (is_wp_error($dispute) || empty($dispute['dispute_id'])) {
1334 new \WP_Error('No dispute ID found!', __('Please check PayPal if the dispute is already accepted or not!', 'fluent-cart'));
1335 }
1336
1337 $disputeId = Arr::get($dispute, 'dispute_id');
1338 }
1339
1340 $note = Arr::get($args, 'dispute_note', 'Accepted full dispute claim!');
1341
1342 $closeDispute = (new API())->createResource('customer/disputes/' . $disputeId . '/accept-claim', ['note' => $note]);
1343
1344 if (is_wp_error($closeDispute)) {
1345 return $closeDispute;
1346 }
1347
1348 return $closeDispute;
1349 }
1350
1351 }
1352