PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
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
← All changes | app/Modules/PaymentMethods/PayPalGateway/PayPal.php +529 -52 1.5.4 → 1.6.6 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 {
@@ -697,9 +1155,9 @@
697 1155 {
698 1156 return null;
699 1157 }
700 1158
701 - public function getEnqueueScriptSrc($hasSubscription = 'no'): array
1159 + public function getEnqueueScriptSrc($hasSubscription = false): array
702 1160 {
703 1161 if ($this->settings->get('checkout_mode') !== 'paypal_pro') {
704 1162 return [];
705 1163 }
@@ -708,12 +1166,28 @@
708 1166 $clientId = sanitize_text_field($clientId);
709 1167
710 1168 $sdkSrc = 'https://www.paypal.com/sdk/js?client-id=' . $clientId;
711 1169
712 - if ('yes' == $hasSubscription) {
1170 + $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1171 +
1172 + if ($renderAsSubscription) {
713 1173 $sdkSrc = add_query_arg(array('vault' => 'true', 'intent' => 'subscription'), $sdkSrc);
714 1174 } else {
715 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 + }
716 1190 }
717 1191 $sdkSrc = apply_filters('fluent_cart/payments/paypal_sdk_src', $sdkSrc, []);
718 1192
719 1193 return [
@@ -732,9 +1206,11 @@
732 1206 public function getLocalizeData(): array
733 1207 {
734 1208 return [
735 1209 'fct_paypal_data' => [
1210 + 'vault_setup_unavailable' => $this->vaultSetupUnavailable ? 'yes' : 'no',
736 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'),
737 1213 'uuid not found' => __('uuid not found', 'fluent-cart'),
738 1214 'Choose any option to continue' => __('Choose any option to continue', 'fluent-cart'),
739 1215 'An unknown error occurred' => __('An unknown error occurred', 'fluent-cart'),
740 1216 'An error occurred while loading PayPal.' => __('An error occurred while loading PayPal.', 'fluent-cart'),
@@ -744,8 +1220,9 @@
744 1220 'No Subscription ID' => __('No Subscription ID', 'fluent-cart'),
745 1221 'no processing' => __('no processing', 'fluent-cart'),
746 1222 'not proper order handler' => __('not proper order handler', 'fluent-cart'),
747 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'),
748 1225 ]
749 1226 ]
750 1227 ];
751 1228 }
@@ -763,31 +1240,11 @@
763 1240 }
764 1241
765 1242 public function getOrderInfo($data)
766 1243 {
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;
1244 + $checkOutHelper = CartCheckoutHelper::make();
1245 + $totalPrice = $this->getPayableNowTotal();
772 1246
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 1247 $items = $checkOutHelper->getItems();
791 1248 $hasSubscription = $this->validateSubscriptions($items);
792 1249
793 1250 $clientId = $this->settings->getPublicKey();
@@ -802,26 +1259,46 @@
802 1259 }
803 1260
804 1261 $paymentArgs['public_key'] = $clientId;
805 1262
1263 + $currency = strtoupper(CurrencySettings::get('currency'));
1264 +
806 1265 $paymentDetails = [
807 1266 'mode' => 'payment',
808 - 'amount' => number_format(Helper::toDecimalWithoutComma($totalPrice), 2, '.', ''),
809 - 'currency' => strtoupper(CurrencySettings::get('currency')),
1267 + 'amount' => PayPalHelper::formatAmount($totalPrice, $currency),
1268 + 'currency' => $currency,
810 1269 ];
811 1270
812 - if ($hasSubscription) {
1271 + $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1272 +
1273 + if ($renderAsSubscription) {
813 1274 $paymentDetails['mode'] = 'subscription';
814 1275 }
815 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 +
816 1292 $this->checkCurrencySupport();
817 1293
818 1294 wp_send_json(
819 1295 [
820 - 'data' => [],
821 - 'payment_args' => $paymentArgs,
822 - 'message' => __('Order info retrieved!', 'fluent-cart'),
823 - 'intent' => $paymentDetails,
1296 + 'data' => [],
1297 + 'payment_args' => $paymentArgs,
1298 + 'message' => __('Order info retrieved!', 'fluent-cart'),
1299 + 'intent' => $paymentDetails,
1300 + 'system_consent' => $systemConsent,
824 1301 ],
825 1302 200
826 1303 );
827 1304