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

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