PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 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 All 51 releases
← All changes | app/Modules/PaymentMethods/PayPalGateway/PayPal.php +536 -52 1.5.1 → 1.7.1 View file →
@@ -2,9 +2,8 @@
2 2
3 3 namespace FluentCart\App\Modules\PaymentMethods\PayPalGateway;
4 4
5 5 use FluentCart\Api\CurrencySettings;
6 -use FluentCart\Api\Orders;
7 6 use FluentCart\App\App;
8 7 use FluentCart\App\Helpers\CartCheckoutHelper;
9 8 use FluentCart\App\Helpers\CartHelper;
10 9 use FluentCart\App\Helpers\Helper;
@@ -25,11 +24,15 @@
25 24 private $methodSlug = 'paypal';
26 25
27 26 public array $supportedFeatures = ['payment', 'refund', 'webhook', 'custom_payment', 'card_update', 'switch_payment_method' => [
28 27 'supported_gateways' => ['stripe', 'paypal'],
29 - ], 'dispute_handler', 'subscriptions'];
28 + ], 'dispute_handler', 'subscriptions', 'resume_subscription', 'system_subscription', 'manual_subscription', 'verify_vendor_ids'];
30 29
30 + private $vaultUserIdToken = '';
31 31
32 + private $vaultSetupUnavailable = false;
33 +
34 +
32 35 public function __construct()
33 36 {
34 37 parent::__construct(
35 38 new PayPalSettingsBase(),
@@ -62,8 +65,10 @@
62 65 public function boot()
63 66 {
64 67 (new IPN())->init();
65 68
69 + add_action('fluent_cart_action_paypal_connect', [ConnectConfig::class, 'handleConnect']);
70 +
66 71 add_action('wp_ajax_nopriv_fluent_cart_confirm_paypal_payment', [$this, 'confirmPayPalSinglePayment']);
67 72 add_action('wp_ajax_fluent_cart_confirm_paypal_payment', [$this, 'confirmPayPalSinglePayment']);
68 73
69 74 add_action('wp_ajax_nopriv_fluent_cart_confirm_paypal_subscription', [$this, 'confirmPayPalSubscription']);
@@ -68,8 +73,11 @@
68 73
69 74 add_action('wp_ajax_nopriv_fluent_cart_confirm_paypal_subscription', [$this, 'confirmPayPalSubscription']);
70 75 add_action('wp_ajax_fluent_cart_confirm_paypal_subscription', [$this, 'confirmPayPalSubscription']);
71 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 +
72 80 add_filter('fluent_cart/payment_methods/paypal_client_id', [$this, 'getClientId'], 10, 2);
73 81
74 82 // add PayPal partner tags
75 83 add_filter('script_loader_tag', function ($tag, $handle) {
@@ -77,8 +85,17 @@
77 85 $tag = str_replace(
78 86 '<script ',
79 87 '<script data-partner-attribution-id="FLUENTCART_SP_PPCP" ', $tag
80 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 + }
81 98 }
82 99 return $tag;
83 100 }, 1, 2);
84 101
@@ -86,8 +103,62 @@
86 103
87 104 public function makePaymentFromPaymentInstance(PaymentInstance $paymentInstance)
88 105 {
89 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 +
90 161 return (new Processor())->handleSubscriptionPaymentFromPaymentInstance($paymentInstance, []);
91 162 }
92 163
93 164 return (new Processor())->handleSinglePayment($paymentInstance, []);
@@ -92,8 +163,128 @@
92 163
93 164 return (new Processor())->handleSinglePayment($paymentInstance, []);
94 165 }
95 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 +
96 287 public function confirmPayPalSinglePayment()
97 288 {
98 289 if (empty(App::request()->get('payId')) || empty(App::request()->get('ref_id'))) {
99 290 wp_send_json([
@@ -154,11 +345,43 @@
154 345 'message' => __('Payment does not match this transaction!', 'fluent-cart')
155 346 ], 422);
156 347 }
157 348
158 - $isPaid = Arr::get($payment_intent, 'status') === 'COMPLETED' || Arr::get($payment_intent, 'status') === 'APPROVED';
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);
159 356
160 - if (!$isPaid) {
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') {
161 384 wp_send_json([
162 385 'status' => 'failed',
163 386 'message' => __('Payment not completed!', 'fluent-cart')
164 387 ], 422);
@@ -172,15 +395,17 @@
172 395 $paidCurrency = strtoupper(Arr::get($unit, 'amount.currency_code', ''));
173 396 }
174 397 }
175 398
176 - if ($paidAmount != $transaction->total) {
399 + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
400 +
401 + if ($paidAmount != $expectedAmount) {
177 402 fluent_cart_warning_log(
178 403 __('PayPal Amount Mismatch Attempt', 'fluent-cart'),
179 404 sprintf(
180 405 /* translators: %1$s: expected amount, %2$s: received amount */
181 406 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. This may indicate payment tampering.', 'fluent-cart'),
182 - Helper::toDecimal($transaction->total),
407 + Helper::toDecimal($expectedAmount),
183 408 Helper::toDecimal($paidAmount)
184 409 ),
185 410 [
186 411 'module_name' => 'order',
@@ -214,29 +439,55 @@
214 439 'message' => __('Payment currency does not match with transaction currency!', 'fluent-cart')
215 440 ], 422);
216 441 }
217 442
218 - $chargeId = Arr::get($payment_intent, 'purchase_units.0.payments.captures.0.id', '');
443 + $capture = Arr::get($payment_intent, 'purchase_units.0.payments.captures.0', []);
444 + $chargeId = Arr::get($capture, 'id', '');
445 + $captureStatus = Arr::get($capture, 'status', '');
219 446
220 - $payPalCaptureLockAcquired = false;
221 - $duplicateCapture = false;
222 -
223 - if ($chargeId) {
224 - $payPalCaptureLockAcquired = $this->acquirePayPalCaptureLock($chargeId);
225 - if (!$payPalCaptureLockAcquired) {
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.
226 453 wp_send_json([
227 454 'status' => 'failed',
228 - 'message' => __('Payment confirmation is already processing. Please try again.', 'fluent-cart')
229 - ], 409);
455 + 'message' => __('This PayPal payment has already been processed!', 'fluent-cart')
456 + ], 422);
230 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);
231 467 }
232 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 +
233 486 // Prevent a single PayPal capture from being applied to more than one
234 487 // transaction (replay/duplicate-capture protection).
235 488 try {
236 - if ($chargeId) {
237 - $duplicateCapture = $this->hasExistingPayPalCapture($transaction, $chargeId);
238 - }
489 + $duplicateCapture = $this->hasExistingPayPalCapture($transaction, $chargeId);
239 490
240 491 if (!$duplicateCapture) {
241 492 // All Verified! Let's update the transaction and order
242 493 (new Processor())->confirmPaymentSuccessByCharge($transaction, [
@@ -248,8 +499,12 @@
248 499 'payer' => Arr::get($payment_intent, 'payer', [])
249 500 ],
250 501 'payment_source' => Arr::get($payment_intent, 'payment_source', []),
251 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);
252 507 }
253 508 } finally {
254 509 if ($payPalCaptureLockAcquired) {
255 510 $this->releasePayPalCaptureLock($chargeId);
@@ -264,9 +519,9 @@
264 519 }
265 520
266 521 wp_send_json([
267 522 'status' => 'success',
268 - 'redirect_url' => $transaction->getReceiptPageUrl(true),
523 + 'redirect_url' => $this->getConfirmRedirectUrl($transaction),
269 524 'order' => [
270 525 'uuid' => $transaction->order->uuid
271 526 ],
272 527 'message' => __('Payment has been paid successfully! Redirecting...', 'fluent-cart')
@@ -272,8 +527,93 @@
272 527 'message' => __('Payment has been paid successfully! Redirecting...', 'fluent-cart')
273 528 ]);
274 529 }
275 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 +
276 616 public function confirmPayPalSubscription()
277 617 {
278 618 if (empty(App::request()->get('subscription_id')) || empty(App::request()->get('ref_id'))) {
279 619 wp_send_json([
@@ -379,9 +719,9 @@
379 719
380 720 wp_send_json([
381 721 'status' => 'success',
382 722 'message' => __('Subscription has been activated successfully!', 'fluent-cart'),
383 - 'redirect_url' => $transaction->getReceiptPageUrl(true),
723 + 'redirect_url' => $this->getConfirmRedirectUrl($transaction),
384 724 'order' => [
385 725 'uuid' => $transaction->order->uuid
386 726 ],
387 727 ], 200);
@@ -391,13 +731,131 @@
391 731 {
392 732 return API::getResource('billing/subscriptions/' . $subscriptionId);
393 733 }
394 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 +
395 754 protected function verifyPayPalPayment($payPalReferenceId)
396 755 {
397 756 return API::verifyPayment($payPalReferenceId);
398 757 }
399 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 +
400 858 protected function hasExistingPayPalCapture(OrderTransaction $transaction, $chargeId)
401 859 {
402 860 return (bool) OrderTransaction::query()
403 861 ->where('vendor_charge_id', $chargeId)
@@ -443,10 +901,10 @@
443 901 if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] != 'POST') {
444 902 return;
445 903 }
446 904
905 + // Sends the HTTP status via status_header() and exits — never returns.
447 906 (new IPN())->processWebhook();
448 - exit(200);
449 907 }
450 908
451 909 public function getTransactionUrl($url, $data)
452 910 {
@@ -602,8 +1060,15 @@
602 1060 'schema' => $testSchema
603 1061 ]
604 1062 ]
605 1063 ],
1064 + 'brand_name' => [
1065 + 'value' => '',
1066 + 'label' => __('Brand name', 'fluent-cart'),
1067 + 'placeholder' => __('Shown on the PayPal checkout instead of your business name', 'fluent-cart'),
1068 + 'tooltip' => __('Optional. Up to 127 characters. Leave empty to show the name on your PayPal business account.', 'fluent-cart'),
1069 + 'type' => 'text',
1070 + ],
606 1071 'provider' => array(
607 1072 'value' => $this->settings->getProviderType(),
608 1073 'label' => __('Provider', 'fluent-cart'),
609 1074 'type' => 'provider'
@@ -697,9 +1162,9 @@
697 1162 {
698 1163 return null;
699 1164 }
700 1165
701 - public function getEnqueueScriptSrc($hasSubscription = 'no'): array
1166 + public function getEnqueueScriptSrc($hasSubscription = false): array
702 1167 {
703 1168 if ($this->settings->get('checkout_mode') !== 'paypal_pro') {
704 1169 return [];
705 1170 }
@@ -708,12 +1173,28 @@
708 1173 $clientId = sanitize_text_field($clientId);
709 1174
710 1175 $sdkSrc = 'https://www.paypal.com/sdk/js?client-id=' . $clientId;
711 1176
712 - if ('yes' == $hasSubscription) {
1177 + $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1178 +
1179 + if ($renderAsSubscription) {
713 1180 $sdkSrc = add_query_arg(array('vault' => 'true', 'intent' => 'subscription'), $sdkSrc);
714 1181 } else {
715 1182 $sdkSrc = add_query_arg(array('currency' => strtoupper(CurrencySettings::get('currency')), 'intent' => 'capture'), $sdkSrc);
1183 +
1184 + if ($this->isZeroPayableSetupCheckout($hasSubscription)) {
1185 + $idToken = API::getUserIdToken();
1186 + if (!is_wp_error($idToken) && $idToken) {
1187 + $this->vaultUserIdToken = $idToken;
1188 + } else {
1189 + // The vault buttons cannot start without the SDK id token —
1190 + // tell the checkout JS to show an error, not a dead button.
1191 + $this->vaultSetupUnavailable = true;
1192 + if (is_wp_error($idToken)) {
1193 + fluent_cart_add_log('PayPal Vault Setup', $idToken->get_error_message(), 'error', ['log_type' => 'payment']);
1194 + }
1195 + }
1196 + }
716 1197 }
717 1198 $sdkSrc = apply_filters('fluent_cart/payments/paypal_sdk_src', $sdkSrc, []);
718 1199
719 1200 return [
@@ -732,9 +1213,11 @@
732 1213 public function getLocalizeData(): array
733 1214 {
734 1215 return [
735 1216 'fct_paypal_data' => [
1217 + 'vault_setup_unavailable' => $this->vaultSetupUnavailable ? 'yes' : 'no',
736 1218 'translations' => [
1219 + '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'),
737 1220 'uuid not found' => __('uuid not found', 'fluent-cart'),
738 1221 'Choose any option to continue' => __('Choose any option to continue', 'fluent-cart'),
739 1222 'An unknown error occurred' => __('An unknown error occurred', 'fluent-cart'),
740 1223 'An error occurred while loading PayPal.' => __('An error occurred while loading PayPal.', 'fluent-cart'),
@@ -744,8 +1227,9 @@
744 1227 'No Subscription ID' => __('No Subscription ID', 'fluent-cart'),
745 1228 'no processing' => __('no processing', 'fluent-cart'),
746 1229 'not proper order handler' => __('not proper order handler', 'fluent-cart'),
747 1230 'Payment confirmation failed' => __('Payment confirmation failed', 'fluent-cart'),
1231 + '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'),
748 1232 ]
749 1233 ]
750 1234 ];
751 1235 }
@@ -763,31 +1247,11 @@
763 1247 }
764 1248
765 1249 public function getOrderInfo($data)
766 1250 {
767 - $cart = CartHelper::getCart();
768 - $checkOutHelper = CartCheckoutHelper::make();
769 - $shippingChargeData = (new WebCheckoutHandler())->getShippingChargeData($cart);
770 - $shippingCharge = Arr::get($shippingChargeData, 'charge');
771 - $totalPrice = $checkOutHelper->getItemsAmountTotal(false) + $shippingCharge;
1251 + $checkOutHelper = CartCheckoutHelper::make();
1252 + $totalPrice = $this->getPayableNowTotal();
772 1253
773 - $tax = $checkOutHelper->getCart()->checkout_data['tax_data'] ?? [];
774 - $taxBehavior = (int) Arr::get($tax, 'tax_behavior', 0);
775 - $storeTaxBehavior = (int) Arr::get($tax, 'store_tax_behavior', $taxBehavior);
776 -
777 - if ($taxBehavior === 1) {
778 - // Pure exclusive — add all tax including fee tax (tax_total contains both).
779 - $totalPrice = $totalPrice + (int) Arr::get($tax, 'tax_total', 0)
780 - + (int) Arr::get($tax, 'shipping_tax', 0);
781 - } elseif ($taxBehavior === 3) {
782 - // Mixed — add only exclusive product tax + fee/shipping if store is exclusive.
783 - $totalPrice = $totalPrice + (int) Arr::get($tax, 'exclusive_tax_total', 0);
784 - if ($storeTaxBehavior === 1) {
785 - $totalPrice = $totalPrice + (int) Arr::get($tax, 'fee_tax', 0)
786 - + (int) Arr::get($tax, 'shipping_tax', 0);
787 - }
788 - }
789 -
790 1254 $items = $checkOutHelper->getItems();
791 1255 $hasSubscription = $this->validateSubscriptions($items);
792 1256
793 1257 $clientId = $this->settings->getPublicKey();
@@ -802,26 +1266,46 @@
802 1266 }
803 1267
804 1268 $paymentArgs['public_key'] = $clientId;
805 1269
1270 + $currency = strtoupper(CurrencySettings::get('currency'));
1271 +
806 1272 $paymentDetails = [
807 1273 'mode' => 'payment',
808 - 'amount' => Helper::toDecimalWithoutComma($totalPrice),
809 - 'currency' => strtoupper(CurrencySettings::get('currency')),
1274 + 'amount' => PayPalHelper::formatAmount($totalPrice, $currency),
1275 + 'currency' => $currency,
810 1276 ];
811 1277
812 - if ($hasSubscription) {
1278 + $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1279 +
1280 + if ($renderAsSubscription) {
813 1281 $paymentDetails['mode'] = 'subscription';
814 1282 }
815 1283
1284 + // System (auto-charged, store-billed) checkout: the buyer's PayPal account
1285 + // is vaulted during the purchase — disclose the save-and-auto-charge next
1286 + // to the PayPal button (PayPal's approval popup carries the agreement too).
1287 + $systemConsent = '';
1288 + if (!$renderAsSubscription && \FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutIsSystem($this)) {
1289 + $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');
1290 +
1291 + // Nothing payable now (free trial): buttons render the vault
1292 + // setup-token flow. The disclosure stays informational — PayPal's
1293 + // approval popup itself carries the explicit save agreement.
1294 + if ($totalPrice <= 0) {
1295 + $paymentDetails['mode'] = 'setup';
1296 + }
1297 + }
1298 +
816 1299 $this->checkCurrencySupport();
817 1300
818 1301 wp_send_json(
819 1302 [
820 - 'data' => [],
821 - 'payment_args' => $paymentArgs,
822 - 'message' => __('Order info retrieved!', 'fluent-cart'),
823 - 'intent' => $paymentDetails,
1303 + 'data' => [],
1304 + 'payment_args' => $paymentArgs,
1305 + 'message' => __('Order info retrieved!', 'fluent-cart'),
1306 + 'intent' => $paymentDetails,
1307 + 'system_consent' => $systemConsent,
824 1308 ],
825 1309 200
826 1310 );
827 1311