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

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

1,357 lines 53.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\PayPalGateway;
4
5 use FluentCart\Api\CurrencySettings;
6 use FluentCart\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 $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
398
399 if ($paidAmount != $expectedAmount) {
400 fluent_cart_warning_log(
401 __('PayPal Amount Mismatch Attempt', 'fluent-cart'),
402 sprintf(
403 /* translators: %1$s: expected amount, %2$s: received amount */
404 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. This may indicate payment tampering.', 'fluent-cart'),
405 Helper::toDecimal($expectedAmount),
406 Helper::toDecimal($paidAmount)
407 ),
408 [
409 'module_name' => 'order',
410 'module_id' => $transaction->order_id,
411 'log_type' => 'api'
412 ]
413 );
414 wp_send_json([
415 'status' => 'failed',
416 'message' => __('Paid amount does not match with transaction amount!', 'fluent-cart')
417 ], 422);
418 }
419
420 if ($paidCurrency && $transaction->currency && strtoupper($transaction->currency) !== $paidCurrency) {
421 fluent_cart_warning_log(
422 __('PayPal Currency Mismatch Attempt', 'fluent-cart'),
423 sprintf(
424 /* translators: %1$s: expected currency, %2$s: received currency */
425 __('Payment currency mismatch detected. Expected: %1$s, Received: %2$s. This may indicate payment tampering.', 'fluent-cart'),
426 $transaction->currency,
427 $paidCurrency
428 ),
429 [
430 'module_name' => 'order',
431 'module_id' => $transaction->order_id,
432 'log_type' => 'api'
433 ]
434 );
435 wp_send_json([
436 'status' => 'failed',
437 'message' => __('Payment currency does not match with transaction currency!', 'fluent-cart')
438 ], 422);
439 }
440
441 $capture = Arr::get($payment_intent, 'purchase_units.0.payments.captures.0', []);
442 $chargeId = Arr::get($capture, 'id', '');
443 $captureStatus = Arr::get($capture, 'status', '');
444
445 if ($captureStatus === 'PENDING') {
446 if (!$this->recordPendingCapture($transaction, $capture)) {
447 // The capture ID already belongs to another transaction. The eventual
448 // PAYMENT.CAPTURE.COMPLETED webhook resolves by vendor_charge_id and will
449 // update that other transaction, so this buyer must never be redirected
450 // to a receipt that will now stay pending forever.
451 wp_send_json([
452 'status' => 'failed',
453 'message' => __('This PayPal payment has already been processed!', 'fluent-cart')
454 ], 422);
455 }
456
457 wp_send_json([
458 'status' => 'pending',
459 'redirect_url' => $this->getConfirmRedirectUrl($transaction),
460 'order' => [
461 'uuid' => $transaction->order->uuid
462 ],
463 'message' => __('Your payment is being reviewed by PayPal. Your order will be confirmed once the payment is completed.', 'fluent-cart')
464 ], 202);
465 }
466
467 if (!$chargeId || $captureStatus !== 'COMPLETED') {
468 wp_send_json([
469 'status' => 'failed',
470 'message' => __('Payment not completed!', 'fluent-cart')
471 ], 422);
472 }
473
474 $duplicateCapture = false;
475
476 $payPalCaptureLockAcquired = $this->acquirePayPalCaptureLock($chargeId);
477 if (!$payPalCaptureLockAcquired) {
478 wp_send_json([
479 'status' => 'failed',
480 'message' => __('Payment confirmation is already processing. Please try again.', 'fluent-cart')
481 ], 409);
482 }
483
484 // Prevent a single PayPal capture from being applied to more than one
485 // transaction (replay/duplicate-capture protection).
486 try {
487 $duplicateCapture = $this->hasExistingPayPalCapture($transaction, $chargeId);
488
489 if (!$duplicateCapture) {
490 // All Verified! Let's update the transaction and order
491 (new Processor())->confirmPaymentSuccessByCharge($transaction, [
492 'vendor_charge_id' => $chargeId,
493 'status' => Status::TRANSACTION_SUCCEEDED,
494 'total' => $paidAmount,
495 'payment_method_type' => 'PayPal',
496 'meta' => [
497 'payer' => Arr::get($payment_intent, 'payer', [])
498 ],
499 'payment_source' => Arr::get($payment_intent, 'payment_source', []),
500 ]);
501
502 // System subscription: persist the vault token from the captured
503 // order (or demote to manual when vaulting did not happen).
504 (new Processor())->maybePersistVaultToken($transaction, $payment_intent);
505 }
506 } finally {
507 if ($payPalCaptureLockAcquired) {
508 $this->releasePayPalCaptureLock($chargeId);
509 }
510 }
511
512 if ($duplicateCapture) {
513 wp_send_json([
514 'status' => 'failed',
515 'message' => __('This PayPal payment has already been processed!', 'fluent-cart')
516 ], 422);
517 }
518
519 wp_send_json([
520 'status' => 'success',
521 'redirect_url' => $this->getConfirmRedirectUrl($transaction),
522 'order' => [
523 'uuid' => $transaction->order->uuid
524 ],
525 'message' => __('Payment has been paid successfully! Redirecting...', 'fluent-cart')
526 ]);
527 }
528
529 /**
530 * AJAX confirmation of a zero-payable system checkout: the buyer approved
531 * the vault setup token in PayPal's popup; exchange it for a durable payment
532 * token, persist it on the subscription, and complete the $0 order.
533 */
534 public function confirmPayPalVaultSetup()
535 {
536 $setupTokenId = sanitize_text_field(App::request()->get('setup_token', ''));
537 $transactionHash = sanitize_text_field(App::request()->get('ref_id', ''));
538
539 if (!$setupTokenId || !$transactionHash) {
540 wp_send_json([
541 'status' => 'failed',
542 'message' => __('No setup token!', 'fluent-cart')
543 ], 422);
544 }
545
546 $transaction = OrderTransaction::query()
547 ->where('uuid', $transactionHash)
548 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
549 ->first();
550
551 if (!$transaction) {
552 wp_send_json([
553 'status' => 'failed',
554 'message' => __('Transaction not found!', 'fluent-cart')
555 ], 423);
556 }
557
558 // Bind the approval to THIS transaction — the setup token id was stored
559 // on it at creation, so a forged ref_id/token pair can never match.
560 if (Arr::get($transaction->meta ?? [], 'paypal_setup_token_id') !== $setupTokenId) {
561 wp_send_json([
562 'status' => 'failed',
563 'message' => __('Setup token does not match this transaction!', 'fluent-cart')
564 ], 422);
565 }
566
567 // Locked on the transaction uuid, not the token — a resubmission mints a
568 // new token, and a token-keyed lock would not serialize the two. The
569 // binding write in handleSetupOnlyPayment takes the same lock.
570 $payPalVaultLockAcquired = Processor::acquireVaultTransactionLock($transactionHash);
571 if (!$payPalVaultLockAcquired) {
572 wp_send_json([
573 'status' => 'failed',
574 'message' => __('Payment confirmation is already processing. Please try again.', 'fluent-cart')
575 ], 409);
576 }
577
578 $result = true;
579
580 try {
581 /** @var OrderTransaction $transaction */
582 $transaction = OrderTransaction::query()->find($transaction->id);
583
584 // Re-check the binding under the lock — the setup token may have
585 // been replaced since the pre-lock check, making this approval stale.
586 if (Arr::get($transaction->meta ?? [], 'paypal_setup_token_id') !== $setupTokenId) {
587 $result = new \WP_Error('stale_setup_token', __('This PayPal approval is no longer valid. Please try again.', 'fluent-cart'));
588 } else {
589 $result = (new Processor())->confirmVaultSetup($transaction, $setupTokenId);
590 }
591 } finally {
592 if ($payPalVaultLockAcquired) {
593 Processor::releaseVaultTransactionLock($transactionHash);
594 }
595 }
596
597 if (is_wp_error($result)) {
598 wp_send_json([
599 'status' => 'failed',
600 'message' => $result->get_error_message()
601 ], 422);
602 }
603
604 wp_send_json([
605 'status' => 'success',
606 'redirect_url' => $this->getConfirmRedirectUrl($transaction),
607 'order' => [
608 'uuid' => $transaction->order->uuid
609 ],
610 'message' => __('Your PayPal account has been saved successfully! Redirecting...', 'fluent-cart')
611 ]);
612 }
613
614 public function confirmPayPalSubscription()
615 {
616 if (empty(App::request()->get('subscription_id')) || empty(App::request()->get('ref_id'))) {
617 wp_send_json([
618 'status' => 'failed',
619 'message' => __('No Subscription ID!', 'fluent-cart')
620 ], 423);
621 }
622
623 $subscriptionId = sanitize_text_field(App::request()->get('subscription_id'));
624
625 $paypalSubscription = $this->getPayPalSubscription($subscriptionId);
626
627 if (is_wp_error($paypalSubscription)) {
628 wp_send_json([
629 'message' => $paypalSubscription->get_error_message(),
630 'status' => 'failed',
631 ], 422);
632 }
633
634
635 $status = Arr::get($paypalSubscription, 'status', '');
636
637 if ($status != 'ACTIVE') {
638 wp_send_json([
639 'status' => 'failed',
640 'message' => __('Subscription is not active', 'fluent-cart')
641 ], 423);
642 }
643
644 $transaction = OrderTransaction::query()->where('uuid', sanitize_text_field(App::request()->get('ref_id')))->first();
645
646 if (!$transaction) {
647 wp_send_json([
648 'status' => 'failed',
649 'message' => __('Transaction not found!', 'fluent-cart')
650 ], 404);
651 }
652
653 $localSubscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
654
655 if (!$localSubscription) {
656 wp_send_json([
657 'status' => 'failed',
658 'message' => __('Subscription not found!', 'fluent-cart')
659 ], 404);
660 }
661
662 // Bind the PayPal subscription to THIS local subscription. FluentCart sets
663 // the local subscription uuid as the PayPal subscription custom_id at
664 // creation (the same field the IPN webhook resolves by), so a forged ref_id
665 // cannot point an unrelated active PayPal subscription at another customer's
666 // transaction.
667 $paypalCustomId = Arr::get($paypalSubscription, 'custom_id', '');
668 if ($paypalCustomId !== $localSubscription->uuid) {
669 wp_send_json([
670 'status' => 'failed',
671 'message' => __('PayPal subscription does not match this transaction!', 'fluent-cart')
672 ], 422);
673 }
674
675 // Prevent the same PayPal subscription from being bound to more than one
676 // local subscription (reuse protection).
677 $alreadyUsed = Subscription::query()
678 ->where('vendor_subscription_id', $subscriptionId)
679 ->where('id', '!=', $localSubscription->id)
680 ->first();
681 if ($alreadyUsed) {
682 wp_send_json([
683 'status' => 'failed',
684 'message' => __('This PayPal subscription has already been used!', 'fluent-cart')
685 ], 422);
686 }
687
688 // Verify the PayPal subscription's plan matches the expected plan
689 if ($localSubscription && $localSubscription->vendor_plan_id) {
690 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
691 if ($paypalPlanId && $paypalPlanId !== $localSubscription->vendor_plan_id) {
692 fluent_cart_add_log(
693 'PayPal Subscription Plan Mismatch',
694 'The PayPal subscription plan ID does not match the expected plan ID for this subscription. This may indicate a configuration issue or potential tampering.',
695 [
696 'module_name' => 'subscription',
697 'module_id' => $localSubscription->id,
698 'log_type' => 'api'
699 ]
700 );
701
702 wp_send_json([
703 'status' => 'failed',
704 'message' => __('PayPal subscription plan does not match the expected plan.', 'fluent-cart')
705 ], 422);
706 }
707 }
708
709 $subscriptionModel = (new Processor())->activateSubscription($paypalSubscription, $transaction);
710
711 if (!$subscriptionModel || !in_array($subscriptionModel->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING], true)) {
712 wp_send_json([
713 'status' => 'failed',
714 'message' => __('Subscription activation failed.', 'fluent-cart')
715 ], 422);
716 }
717
718 wp_send_json([
719 'status' => 'success',
720 'message' => __('Subscription has been activated successfully!', 'fluent-cart'),
721 'redirect_url' => $this->getConfirmRedirectUrl($transaction),
722 'order' => [
723 'uuid' => $transaction->order->uuid
724 ],
725 ], 200);
726 }
727
728 protected function getPayPalSubscription($subscriptionId)
729 {
730 return API::getResource('billing/subscriptions/' . $subscriptionId);
731 }
732
733 /**
734 * Post-payment redirect for PayPal confirm responses. The canonical
735 * fluent_cart/payment/success_url filter fires inside getSuccessUrl();
736 * the receipt_page_url filter is bridged for existing consumers of the
737 * previous PayPal redirect and will be dropped from this path later.
738 */
739 private function getConfirmRedirectUrl($transaction)
740 {
741 $url = $transaction->getSuccessUrl();
742
743 return apply_filters_deprecated(
744 'fluent_cart/transaction/receipt_page_url',
745 [$url, ['transaction' => $transaction, 'order' => $transaction->order]],
746 '1.6.2',
747 'fluent_cart/payment/success_url',
748 '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.'
749 );
750 }
751
752 protected function verifyPayPalPayment($payPalReferenceId)
753 {
754 return API::verifyPayment($payPalReferenceId);
755 }
756
757 protected function capturePayPalPayment($payPalReferenceId)
758 {
759 return API::captureOrder($payPalReferenceId);
760 }
761
762 /**
763 * Detects PayPal's "this order was already captured" response. In the normal flow the
764 * browser captures first, so our server-side capture of the same order legitimately
765 * fails with 422 UNPROCESSABLE_ENTITY / issue ORDER_ALREADY_CAPTURED — that is expected
766 * and must be treated as success (re-GET the order), not as a payment failure.
767 *
768 * @param \WP_Error $error
769 * @return bool
770 */
771 protected function isAlreadyCapturedError($error)
772 {
773 if ($error->get_error_code() === 'ORDER_ALREADY_CAPTURED') {
774 return true;
775 }
776
777 $body = $error->get_error_data();
778 if (is_array($body)) {
779 $issue = Arr::get($body, 'details.0.issue', '');
780 if ($issue === 'ORDER_ALREADY_CAPTURED') {
781 return true;
782 }
783 }
784
785 return false;
786 }
787
788 /**
789 * A PENDING capture has moved no money. Bind its id to the transaction so the
790 * PAYMENT.CAPTURE.COMPLETED webhook resolves it without the order-lookup
791 * fallback, and record PayPal's hold reason (ECHECK, PENDING_REVIEW,
792 * RECEIVING_PREFERENCE_MANDATES_MANUAL_ACTION, ...) on the order for support.
793 *
794 * Shares the completed-path capture lock so a concurrent confirmation for the
795 * same charge id cannot bind it to two transactions. Returns false when the
796 * charge id already belongs to another transaction — the caller must not treat
797 * that as pending-for-this-order.
798 *
799 * @param OrderTransaction $transaction
800 * @param array $capture
801 * @return bool
802 */
803 protected function recordPendingCapture(OrderTransaction $transaction, $capture)
804 {
805 $chargeId = Arr::get($capture, 'id', '');
806
807 if (!$chargeId) {
808 return true;
809 }
810
811 if ($transaction->vendor_charge_id === $chargeId) {
812 return true;
813 }
814
815 $payPalCaptureLockAcquired = $this->acquirePayPalCaptureLock($chargeId);
816 if (!$payPalCaptureLockAcquired) {
817 return false;
818 }
819
820 try {
821 if ($this->hasExistingPayPalCapture($transaction, $chargeId)) {
822 return false;
823 }
824
825 if (!$transaction->vendor_charge_id) {
826 $transaction->update([
827 'vendor_charge_id' => $chargeId,
828 'payment_method' => 'paypal',
829 ]);
830 }
831 } finally {
832 $this->releasePayPalCaptureLock($chargeId);
833 }
834
835 $reason = Arr::get($capture, 'status_details.reason', '');
836
837 fluent_cart_add_log(
838 __('PayPal Payment Pending', 'fluent-cart'),
839 sprintf(
840 /* translators: %1$s: PayPal capture id, %2$s: PayPal hold reason */
841 __('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'),
842 $chargeId ? $chargeId : 'unknown',
843 $reason ? $reason : 'unknown'
844 ),
845 'info',
846 [
847 'module_name' => 'order',
848 'module_id' => $transaction->order_id,
849 'log_type' => 'api'
850 ]
851 );
852
853 return true;
854 }
855
856 protected function hasExistingPayPalCapture(OrderTransaction $transaction, $chargeId)
857 {
858 return (bool) OrderTransaction::query()
859 ->where('vendor_charge_id', $chargeId)
860 ->where('id', '!=', $transaction->id)
861 ->first();
862 }
863
864 protected function acquirePayPalCaptureLock($chargeId)
865 {
866 global $wpdb;
867
868 $result = $wpdb->get_var($wpdb->prepare(
869 'SELECT GET_LOCK(%s, %d)',
870 $this->getPayPalCaptureLockName($chargeId),
871 10
872 ));
873
874 return (string) $result === '1';
875 }
876
877 protected function releasePayPalCaptureLock($chargeId)
878 {
879 global $wpdb;
880
881 $wpdb->get_var($wpdb->prepare(
882 'SELECT RELEASE_LOCK(%s)',
883 $this->getPayPalCaptureLockName($chargeId)
884 ));
885 }
886
887 protected function getPayPalCaptureLockName($chargeId)
888 {
889 return 'fluent_cart_paypal_capture_' . md5($chargeId);
890 }
891
892 public function getClientId($value, $args)
893 {
894 return $this->settings->getPublicKey();
895 }
896
897 public function handleIPN()
898 {
899 if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] != 'POST') {
900 return;
901 }
902
903 // Sends the HTTP status via status_header() and exits — never returns.
904 (new IPN())->processWebhook();
905 }
906
907 public function getTransactionUrl($url, $data)
908 {
909 if (Arr::get($data, 'payment_mode') === 'test') {
910 return 'https://www.sandbox.paypal.com/activity/payment/' . Arr::get($data, 'vendor_charge_id');
911 }
912
913 return 'https://www.paypal.com/activity/payment/' . Arr::get($data, 'vendor_charge_id');
914 }
915
916 public function appAuthenticator($request)
917 {
918 ConnectConfig::parseConnectInfos($request);
919 }
920
921 public function getSubscriptionUrl($url, $data)
922 {
923 if (Arr::get($data, 'payment_mode') === 'test') {
924 return 'https://www.sandbox.paypal.com/billing/subscriptions/' . Arr::get($data, 'vendor_subscription_id');
925 }
926
927 return 'https://www.paypal.com/billing/subscriptions' . Arr::get($data, 'vendor_subscription_id');
928 }
929
930 public static function beforeSettingsUpdate($data, $oldSettings): array
931 {
932 if (Arr::get($data, 'payment_mode') === 'live') {
933 $data['live_client_secret'] = Helper::encryptKey($data['live_client_secret']);
934 } else {
935 $data['test_client_secret'] = Helper::encryptKey($data['test_client_secret']);
936 }
937
938 if (isset($data['define_test_keys'])) {
939 unset($data['define_test_keys']);
940 }
941 if (isset($data['define_live_keys'])) {
942 unset($data['define_live_keys']);
943 }
944 //clean existing access token if exist, fix for: api key change authentication error
945 fluent_cart_update_option('_paypal_access_token_' . Arr::get($data, 'payment_mode'), []);
946
947 return $data;
948 }
949
950 public function isEnabled(): bool
951 {
952 return $this->settings->isActive();
953 }
954
955 /**
956 * Connect configuration should return
957 */
958 public function getConnectInfo()
959 {
960 return ConnectConfig::getConnectConfig();
961 }
962
963 public function disconnect($data)
964 {
965 return ConnectConfig::disconnect($data);
966 }
967
968 public function getWebhookInfo($mode = 'test')
969 {
970 $webhookId = $this->settings->get($mode . '_webhook_id');
971 $webhookEvents = $this->settings->get($mode . '_webhook_events');
972
973 if (!$webhookId || !$webhookEvents) {
974 return false;
975 }
976
977 /**
978 * return string
979 * webhook url also in code formatted and add copy button
980 * webhook id
981 * webhook events (list of events, and every list item should be code formatted), if not empty
982 * $webhookUrl = home_url('/wp-json/fluent-cart/v2/webhook?fct_payment_listener=1&method=paypal')
983 */
984
985 $webhookInfo = '';
986 if ($webhookId) {
987 $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>';
988 }
989 if ($webhookEvents) {
990 $webhookInfo .= '<p>' . __('and now watching for Webhook Events listed bellow:', 'fluent-cart') . '</p><p style="word-wrap: break-word;
991 font-size: 12px;" class="copyable-content">';
992 foreach ($webhookEvents as $event) {
993 $webhookInfo .= $event['name'] . ' | ';
994 }
995 $webhookInfo .= '</p>';
996 }
997
998 return $webhookInfo;
999 }
1000
1001 public function fields()
1002 {
1003 $testSchema = [
1004 'webhook_instruction' => [
1005 'value' => Webhook::webhookInstruction(),
1006 'label' => __('Webhook Setup', 'fluent-cart'),
1007 'type' => 'html_attr'
1008 ],
1009 'test_webhook_id' => [
1010 'value' => '',
1011 'placeholder' => 'Webhook ID',
1012 'required' => true,
1013 'label' => __('Test Webhook ID (Copy the webhook id and paste bellow)', 'fluent-cart'),
1014 'type' => 'text'
1015 ],
1016 ];
1017
1018 $liveSchema = [
1019 'webhook_instruction' => [
1020 'value' => Webhook::webhookInstruction(),
1021 'label' => __('Webhook Setup', 'fluent-cart'),
1022 'type' => 'html_attr'
1023 ],
1024 'live_webhook_id' => [
1025 'value' => '',
1026 'placeholder' => 'Webhook ID',
1027 'required' => true,
1028 'label' => __('Live Webhook ID (Copy the webhook id and paste bellow)', 'fluent-cart'),
1029 'type' => 'text'
1030 ],
1031 ];
1032
1033 // if not defined property then no need to show webhook instruction
1034 if ($this->settings->getProviderType() !== 'api_keys') {
1035 $testSchema = [];
1036 $liveSchema = [];
1037 }
1038
1039 $payPalFields = array(
1040 'notice' => [
1041 'value' => $this->renderStoreModeNotice(),
1042 'label' => __('PayPal', 'fluent-cart'),
1043 'type' => 'notice'
1044 ],
1045 'payment_mode' => [
1046 'type' => 'tabs',
1047 'schema' => [
1048 [
1049 'type' => 'tab',
1050 'label' => __('Live credentials', 'fluent-cart'),
1051 'value' => 'live',
1052 'schema' => $liveSchema
1053 ],
1054 [
1055 'type' => 'tab',
1056 'label' => __('Test credentials', 'fluent-cart'),
1057 'value' => 'test',
1058 'schema' => $testSchema
1059 ]
1060 ]
1061 ],
1062 'provider' => array(
1063 'value' => $this->settings->getProviderType(),
1064 'label' => __('Provider', 'fluent-cart'),
1065 'type' => 'provider'
1066 ),
1067 'webhook_info_test' => array(
1068 'info' => $this->getWebhookInfo('test'),
1069 'label' => __('Webhook Info', 'fluent-cart'),
1070 'type' => 'webhook_info',
1071 'mode' => 'test'
1072 ),
1073 'webhook_info_live' => array(
1074 'info' => $this->getWebhookInfo('live'),
1075 'label' => __('Webhook Info', 'fluent-cart'),
1076 'type' => 'webhook_info',
1077 'mode' => 'live'
1078 ),
1079 'is_pro_item' => array(
1080 'value' => 'no',
1081 'label' => __('PayPal', 'fluent-cart'),
1082 'type' => 'validate'
1083 ),
1084 );
1085
1086 return $payPalFields;
1087 }
1088
1089 public function webHookPaymentMethodName()
1090 {
1091 return $this->methodSlug;
1092 }
1093
1094 public static function validateSettings($data): array
1095 {
1096 $mode = Arr::get($data, 'payment_mode', 'test');
1097 $provider = Arr::get($data, 'provider', 'connect');
1098
1099 if ($provider === 'api_keys') {
1100 if ($mode === 'live') {
1101 $clientId = defined('FCT_PAYPAL_LIVE_PUBLIC_KEY') ? FCT_PAYPAL_LIVE_PUBLIC_KEY : Arr::get($data, 'live_client_id');
1102 $clientSecret = defined('FCT_PAYPAL_LIVE_SECRET_KEY') ? FCT_PAYPAL_LIVE_SECRET_KEY : Arr::get($data, 'live_client_secret');
1103 } else {
1104 $clientId = defined('FCT_PAYPAL_TEST_PUBLIC_KEY') ? FCT_PAYPAL_TEST_PUBLIC_KEY : Arr::get($data, 'test_client_id');
1105 $clientSecret = defined('FCT_PAYPAL_TEST_SECRET_KEY') ? FCT_PAYPAL_TEST_SECRET_KEY : Arr::get($data, 'test_client_secret');
1106 }
1107
1108 return static::validateApiCredentials($clientId, $clientSecret, $mode);
1109
1110 }
1111
1112 $clientId = Arr::get($data, "{$mode}_client_id");
1113 $clientSecret = Arr::get($data, "{$mode}_client_secret");
1114
1115 if (!$clientId || !$clientSecret) {
1116 return [
1117 'status' => 'failed',
1118 'message' => $mode === 'live' ? __('PayPal live credentials are required!', 'fluent-cart') : __('PayPal test credentials are required!', 'fluent-cart'),
1119 ];
1120 }
1121
1122 return [
1123 'status' => 'success',
1124 'message' => __('Credentials are valid!', 'fluent-cart')
1125 ];
1126
1127 }
1128
1129 private static function validateApiCredentials($clientId, $clientSecret, $mode): array
1130 {
1131 $result = API::validateCredentials($clientId, $clientSecret, $mode);
1132
1133 if (is_wp_error($result)) {
1134 return [
1135 'status' => 'failed',
1136 'message' => $result->get_error_message()
1137 ];
1138 }
1139
1140 return [
1141 'status' => 'success',
1142 'message' => __('Credentials are valid!', 'fluent-cart')
1143 ];
1144
1145 }
1146
1147 /*
1148 * Default sdk enqueue version is the plugin version
1149 * if any sdk require a specific version, then override this method
1150 * or to remove a version, return null
1151 */
1152 public function getEnqueueVersion()
1153 {
1154 return null;
1155 }
1156
1157 public function getEnqueueScriptSrc($hasSubscription = false): array
1158 {
1159 if ($this->settings->get('checkout_mode') !== 'paypal_pro') {
1160 return [];
1161 }
1162
1163 $clientId = $this->settings->getPublicKey();
1164 $clientId = sanitize_text_field($clientId);
1165
1166 $sdkSrc = 'https://www.paypal.com/sdk/js?client-id=' . $clientId;
1167
1168 $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1169
1170 if ($renderAsSubscription) {
1171 $sdkSrc = add_query_arg(array('vault' => 'true', 'intent' => 'subscription'), $sdkSrc);
1172 } else {
1173 $sdkSrc = add_query_arg(array('currency' => strtoupper(CurrencySettings::get('currency')), 'intent' => 'capture'), $sdkSrc);
1174
1175 if ($this->isZeroPayableSetupCheckout($hasSubscription)) {
1176 $idToken = API::getUserIdToken();
1177 if (!is_wp_error($idToken) && $idToken) {
1178 $this->vaultUserIdToken = $idToken;
1179 } else {
1180 // The vault buttons cannot start without the SDK id token —
1181 // tell the checkout JS to show an error, not a dead button.
1182 $this->vaultSetupUnavailable = true;
1183 if (is_wp_error($idToken)) {
1184 fluent_cart_add_log('PayPal Vault Setup', $idToken->get_error_message(), 'error', ['log_type' => 'payment']);
1185 }
1186 }
1187 }
1188 }
1189 $sdkSrc = apply_filters('fluent_cart/payments/paypal_sdk_src', $sdkSrc, []);
1190
1191 return [
1192 [
1193 'handle' => 'fluent-cart-checkout-sdk-paypal',
1194 'src' => $sdkSrc,
1195 ],
1196 [
1197 'handle' => 'fluent-cart-checkout-handler-paypal',
1198 'src' => Vite::getEnqueuePath('public/payment-methods/paypal-checkout.js'),
1199 'deps' => ['fluent-cart-checkout-sdk-paypal']
1200 ]
1201 ];
1202 }
1203
1204 public function getLocalizeData(): array
1205 {
1206 return [
1207 'fct_paypal_data' => [
1208 'vault_setup_unavailable' => $this->vaultSetupUnavailable ? 'yes' : 'no',
1209 'translations' => [
1210 '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'),
1211 'uuid not found' => __('uuid not found', 'fluent-cart'),
1212 'Choose any option to continue' => __('Choose any option to continue', 'fluent-cart'),
1213 'An unknown error occurred' => __('An unknown error occurred', 'fluent-cart'),
1214 'An error occurred while loading PayPal.' => __('An error occurred while loading PayPal.', 'fluent-cart'),
1215 'Loading Payment Processor...' => __('Loading Payment Processor...', 'fluent-cart'),
1216 'Order creation failed' => __('Order creation failed', 'fluent-cart'),
1217 'Not proper order handler' => __('Not proper order handler', 'fluent-cart'),
1218 'No Subscription ID' => __('No Subscription ID', 'fluent-cart'),
1219 'no processing' => __('no processing', 'fluent-cart'),
1220 'not proper order handler' => __('not proper order handler', 'fluent-cart'),
1221 'Payment confirmation failed' => __('Payment confirmation failed', 'fluent-cart'),
1222 'Your payment is being reviewed. We will confirm your order once it completes.' => __('Your payment is being reviewed. We will confirm your order once it completes.', 'fluent-cart'),
1223 ]
1224 ]
1225 ];
1226 }
1227
1228 public function processRefund($transaction, $amount, $args)
1229 {
1230 if (!$amount) {
1231 return new \WP_Error(
1232 'fluent_cart_stripe_refund_error',
1233 __('Refund amount is required.', 'fluent-cart')
1234 );
1235 }
1236
1237 return PayPalHelper::processRemoteRefund($transaction, $amount, $args);
1238 }
1239
1240 public function getOrderInfo($data)
1241 {
1242 $checkOutHelper = CartCheckoutHelper::make();
1243 $totalPrice = $this->getPayableNowTotal();
1244
1245 $items = $checkOutHelper->getItems();
1246 $hasSubscription = $this->validateSubscriptions($items);
1247
1248 $clientId = $this->settings->getPublicKey();
1249
1250 if (empty($clientId)) {
1251 $message = __('Please provide a valid Client Id!', 'fluent-cart');
1252 fluent_cart_add_log('PayPal Credential Validation', $message, 'error', ['log_type' => 'payment']);
1253 wp_send_json([
1254 'status' => 'failed',
1255 'message' => __('No valid Client ID found!', 'fluent-cart')
1256 ], 422);
1257 }
1258
1259 $paymentArgs['public_key'] = $clientId;
1260
1261 $currency = strtoupper(CurrencySettings::get('currency'));
1262
1263 $paymentDetails = [
1264 'mode' => 'payment',
1265 'amount' => PayPalHelper::formatAmount($totalPrice, $currency),
1266 'currency' => $currency,
1267 ];
1268
1269 $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1270
1271 if ($renderAsSubscription) {
1272 $paymentDetails['mode'] = 'subscription';
1273 }
1274
1275 // System (auto-charged, store-billed) checkout: the buyer's PayPal account
1276 // is vaulted during the purchase — disclose the save-and-auto-charge next
1277 // to the PayPal button (PayPal's approval popup carries the agreement too).
1278 $systemConsent = '';
1279 if (!$renderAsSubscription && \FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutIsSystem($this)) {
1280 $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');
1281
1282 // Nothing payable now (free trial): buttons render the vault
1283 // setup-token flow. The disclosure stays informational — PayPal's
1284 // approval popup itself carries the explicit save agreement.
1285 if ($totalPrice <= 0) {
1286 $paymentDetails['mode'] = 'setup';
1287 }
1288 }
1289
1290 $this->checkCurrencySupport();
1291
1292 wp_send_json(
1293 [
1294 'data' => [],
1295 'payment_args' => $paymentArgs,
1296 'message' => __('Order info retrieved!', 'fluent-cart'),
1297 'intent' => $paymentDetails,
1298 'system_consent' => $systemConsent,
1299 ],
1300 200
1301 );
1302
1303 }
1304
1305 public function checkCurrencySupport()
1306 {
1307 $currency = CurrencySettings::get('currency');
1308
1309 if (!in_array(strtoupper($currency), self::getPaypalSupportedCurrency())) {
1310 wp_send_json([
1311 'status' => 'failed',
1312 'message' => __('PayPal does not support the currency you are using!', 'fluent-cart')
1313 ], 422);
1314 }
1315 }
1316
1317 public function isCurrencySupported(): bool
1318 {
1319 $currency = CurrencySettings::get('currency');
1320 return in_array(strtoupper($currency), self::getPaypalSupportedCurrency());
1321 }
1322
1323 public static function getPaypalSupportedCurrency(): array
1324 {
1325 return [
1326 'USD', 'EUR', 'GBP', 'AUD', 'CAD', 'JPY', 'NZD', 'CHF', 'HKD', 'SGD', 'SEK', 'DKK', 'PLN', 'NOK', 'HUF', 'CZK', 'ILS', 'MXN', 'MYR', 'BRL', 'PHP', 'TWD', 'THB'
1327 ];
1328 }
1329
1330 public function acceptRemoteDispute($transaction, $args = [])
1331 {
1332 $disputeId = Arr::get($transaction->meta, 'dispute_id');
1333 $dispute = (new API())->getResource('customer/disputes/'. $disputeId);
1334
1335 if (!$disputeId) {
1336 $dispute = (new API())->getResource('customer/disputes/'. $disputeId);
1337
1338 if (is_wp_error($dispute) || empty($dispute['dispute_id'])) {
1339 new \WP_Error('No dispute ID found!', __('Please check PayPal if the dispute is already accepted or not!', 'fluent-cart'));
1340 }
1341
1342 $disputeId = Arr::get($dispute, 'dispute_id');
1343 }
1344
1345 $note = Arr::get($args, 'dispute_note', 'Accepted full dispute claim!');
1346
1347 $closeDispute = (new API())->createResource('customer/disputes/' . $disputeId . '/accept-claim', ['note' => $note]);
1348
1349 if (is_wp_error($closeDispute)) {
1350 return $closeDispute;
1351 }
1352
1353 return $closeDispute;
1354 }
1355
1356 }
1357