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 +661 -55 1.4.2 → 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(),
@@ -51,9 +54,9 @@
51 54 'label' => 'PayPal',
52 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'),
53 56 'logo' => Vite::getAssetUrl("images/payment-methods/paypal-icon.svg"),
54 57 'icon' => Vite::getAssetUrl("images/payment-methods/paypal-icon.svg"),
55 - 'brand_color' => '#4f94d4',
58 + 'brand_color' => '#60cdff',
56 59 'status' => $this->settings->get('is_active') === 'yes',
57 60 'upcoming' => false,
58 61 'supported_features' => $this->supportedFeatures
59 62 ];
@@ -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([
@@ -104,9 +295,9 @@
104 295
105 296 $payPalReferenceId = sanitize_text_field(App::request()->get('payId'));
106 297 $transactionHash = sanitize_text_field(App::request()->get('ref_id'));
107 298
108 - $payment_intent = API::verifyPayment($payPalReferenceId);
299 + $payment_intent = $this->verifyPayPalPayment($payPalReferenceId);
109 300
110 301 if (is_wp_error($payment_intent)) {
111 302 wp_send_json([
112 303 'status' => 'failed',
@@ -137,11 +328,60 @@
137 328 'message' => __('Transaction not found!', 'fluent-cart')
138 329 ], 423);
139 330 }
140 331
141 - $isPaid = Arr::get($payment_intent, 'status') === 'COMPLETED' || Arr::get($payment_intent, 'status') === 'APPROVED';
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 + }
142 348
143 - if (!$isPaid) {
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') {
144 384 wp_send_json([
145 385 'status' => 'failed',
146 386 'message' => __('Payment not completed!', 'fluent-cart')
147 387 ], 422);
@@ -155,15 +395,17 @@
155 395 $paidCurrency = strtoupper(Arr::get($unit, 'amount.currency_code', ''));
156 396 }
157 397 }
158 398
159 - if ($paidAmount != $transaction->total) {
399 + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
400 +
401 + if ($paidAmount != $expectedAmount) {
160 402 fluent_cart_warning_log(
161 403 __('PayPal Amount Mismatch Attempt', 'fluent-cart'),
162 404 sprintf(
163 405 /* translators: %1$s: expected amount, %2$s: received amount */
164 406 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. This may indicate payment tampering.', 'fluent-cart'),
165 - Helper::toDecimal($transaction->total),
407 + Helper::toDecimal($expectedAmount),
166 408 Helper::toDecimal($paidAmount)
167 409 ),
168 410 [
169 411 'module_name' => 'order',
@@ -197,30 +439,178 @@
197 439 'message' => __('Payment currency does not match with transaction currency!', 'fluent-cart')
198 440 ], 422);
199 441 }
200 442
201 - $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', '');
202 446
203 - // All Verified! Let's update the transaction and order
204 - (new Processor())->confirmPaymentSuccessByCharge($transaction, [
205 - 'vendor_charge_id' => $chargeId,
206 - 'status' => Status::TRANSACTION_SUCCEEDED,
207 - 'total' => $paidAmount,
208 - 'payment_method_type' => 'PayPal',
209 - 'meta' => [
210 - 'payer' => Arr::get($payment_intent, 'payer', [])
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
211 526 ],
212 - 'payment_source' => Arr::get($payment_intent, 'payment_source', []),
527 + 'message' => __('Payment has been paid successfully! Redirecting...', 'fluent-cart')
213 528 ]);
529 + }
214 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', ''));
215 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 +
216 606 wp_send_json([
217 607 'status' => 'success',
218 - 'redirect_url' => $transaction->getReceiptPageUrl(true),
608 + 'redirect_url' => $this->getConfirmRedirectUrl($transaction),
219 609 'order' => [
220 610 'uuid' => $transaction->order->uuid
221 611 ],
222 - 'message' => __('Payment has been paid successfully! Redirecting...', 'fluent-cart')
612 + 'message' => __('Your PayPal account has been saved successfully! Redirecting...', 'fluent-cart')
223 613 ]);
224 614 }
225 615
226 616 public function confirmPayPalSubscription()
@@ -233,9 +623,9 @@
233 623 }
234 624
235 625 $subscriptionId = sanitize_text_field(App::request()->get('subscription_id'));
236 626
237 - $paypalSubscription = API::getResource('billing/subscriptions/' . $subscriptionId);
627 + $paypalSubscription = $this->getPayPalSubscription($subscriptionId);
238 628
239 629 if (is_wp_error($paypalSubscription)) {
240 630 wp_send_json([
241 631 'message' => $paypalSubscription->get_error_message(),
@@ -261,11 +651,44 @@
261 651 'message' => __('Transaction not found!', 'fluent-cart')
262 652 ], 404);
263 653 }
264 654
265 - // Verify the PayPal subscription's plan matches the expected plan
266 655 $localSubscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
267 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
268 691 if ($localSubscription && $localSubscription->vendor_plan_id) {
269 692 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
270 693 if ($paypalPlanId && $paypalPlanId !== $localSubscription->vendor_plan_id) {
271 694 fluent_cart_add_log(
@@ -296,9 +719,9 @@
296 719
297 720 wp_send_json([
298 721 'status' => 'success',
299 722 'message' => __('Subscription has been activated successfully!', 'fluent-cart'),
300 - 'redirect_url' => $transaction->getReceiptPageUrl(true),
723 + 'redirect_url' => $this->getConfirmRedirectUrl($transaction),
301 724 'order' => [
302 725 'uuid' => $transaction->order->uuid
303 726 ],
304 727 ], 200);
@@ -303,8 +726,172 @@
303 726 ],
304 727 ], 200);
305 728 }
306 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 +
307 894 public function getClientId($value, $args)
308 895 {
309 896 return $this->settings->getPublicKey();
310 897 }
@@ -314,10 +901,10 @@
314 901 if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] != 'POST') {
315 902 return;
316 903 }
317 904
905 + // Sends the HTTP status via status_header() and exits — never returns.
318 906 (new IPN())->processWebhook();
319 - exit(200);
320 907 }
321 908
322 909 public function getTransactionUrl($url, $data)
323 910 {
@@ -568,9 +1155,9 @@
568 1155 {
569 1156 return null;
570 1157 }
571 1158
572 - public function getEnqueueScriptSrc($hasSubscription = 'no'): array
1159 + public function getEnqueueScriptSrc($hasSubscription = false): array
573 1160 {
574 1161 if ($this->settings->get('checkout_mode') !== 'paypal_pro') {
575 1162 return [];
576 1163 }
@@ -579,12 +1166,28 @@
579 1166 $clientId = sanitize_text_field($clientId);
580 1167
581 1168 $sdkSrc = 'https://www.paypal.com/sdk/js?client-id=' . $clientId;
582 1169
583 - if ('yes' == $hasSubscription) {
1170 + $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1171 +
1172 + if ($renderAsSubscription) {
584 1173 $sdkSrc = add_query_arg(array('vault' => 'true', 'intent' => 'subscription'), $sdkSrc);
585 1174 } else {
586 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 + }
587 1190 }
588 1191 $sdkSrc = apply_filters('fluent_cart/payments/paypal_sdk_src', $sdkSrc, []);
589 1192
590 1193 return [
@@ -603,9 +1206,11 @@
603 1206 public function getLocalizeData(): array
604 1207 {
605 1208 return [
606 1209 'fct_paypal_data' => [
1210 + 'vault_setup_unavailable' => $this->vaultSetupUnavailable ? 'yes' : 'no',
607 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'),
608 1213 'uuid not found' => __('uuid not found', 'fluent-cart'),
609 1214 'Choose any option to continue' => __('Choose any option to continue', 'fluent-cart'),
610 1215 'An unknown error occurred' => __('An unknown error occurred', 'fluent-cart'),
611 1216 'An error occurred while loading PayPal.' => __('An error occurred while loading PayPal.', 'fluent-cart'),
@@ -615,8 +1220,9 @@
615 1220 'No Subscription ID' => __('No Subscription ID', 'fluent-cart'),
616 1221 'no processing' => __('no processing', 'fluent-cart'),
617 1222 'not proper order handler' => __('not proper order handler', 'fluent-cart'),
618 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'),
619 1225 ]
620 1226 ]
621 1227 ];
622 1228 }
@@ -634,31 +1240,11 @@
634 1240 }
635 1241
636 1242 public function getOrderInfo($data)
637 1243 {
638 - $cart = CartHelper::getCart();
639 - $checkOutHelper = CartCheckoutHelper::make();
640 - $shippingChargeData = (new WebCheckoutHandler())->getShippingChargeData($cart);
641 - $shippingCharge = Arr::get($shippingChargeData, 'charge');
642 - $totalPrice = $checkOutHelper->getItemsAmountTotal(false) + $shippingCharge;
1244 + $checkOutHelper = CartCheckoutHelper::make();
1245 + $totalPrice = $this->getPayableNowTotal();
643 1246
644 - $tax = $checkOutHelper->getCart()->checkout_data['tax_data'] ?? [];
645 - $taxBehavior = (int) Arr::get($tax, 'tax_behavior', 0);
646 - $storeTaxBehavior = (int) Arr::get($tax, 'store_tax_behavior', $taxBehavior);
647 -
648 - if ($taxBehavior === 1) {
649 - // Pure exclusive — add all tax including fee tax (tax_total contains both).
650 - $totalPrice = $totalPrice + (int) Arr::get($tax, 'tax_total', 0)
651 - + (int) Arr::get($tax, 'shipping_tax', 0);
652 - } elseif ($taxBehavior === 3) {
653 - // Mixed — add only exclusive product tax + fee/shipping if store is exclusive.
654 - $totalPrice = $totalPrice + (int) Arr::get($tax, 'exclusive_tax_total', 0);
655 - if ($storeTaxBehavior === 1) {
656 - $totalPrice = $totalPrice + (int) Arr::get($tax, 'fee_tax', 0)
657 - + (int) Arr::get($tax, 'shipping_tax', 0);
658 - }
659 - }
660 -
661 1247 $items = $checkOutHelper->getItems();
662 1248 $hasSubscription = $this->validateSubscriptions($items);
663 1249
664 1250 $clientId = $this->settings->getPublicKey();
@@ -673,26 +1259,46 @@
673 1259 }
674 1260
675 1261 $paymentArgs['public_key'] = $clientId;
676 1262
1263 + $currency = strtoupper(CurrencySettings::get('currency'));
1264 +
677 1265 $paymentDetails = [
678 1266 'mode' => 'payment',
679 - 'amount' => Helper::toDecimalWithoutComma($totalPrice),
680 - 'currency' => strtoupper(CurrencySettings::get('currency')),
1267 + 'amount' => PayPalHelper::formatAmount($totalPrice, $currency),
1268 + 'currency' => $currency,
681 1269 ];
682 1270
683 - if ($hasSubscription) {
1271 + $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1272 +
1273 + if ($renderAsSubscription) {
684 1274 $paymentDetails['mode'] = 'subscription';
685 1275 }
686 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 +
687 1292 $this->checkCurrencySupport();
688 1293
689 1294 wp_send_json(
690 1295 [
691 - 'data' => [],
692 - 'payment_args' => $paymentArgs,
693 - 'message' => __('Order info retrieved!', 'fluent-cart'),
694 - 'intent' => $paymentDetails,
1296 + 'data' => [],
1297 + 'payment_args' => $paymentArgs,
1298 + 'message' => __('Order info retrieved!', 'fluent-cart'),
1299 + 'intent' => $paymentDetails,
1300 + 'system_consent' => $systemConsent,
695 1301 ],
696 1302 200
697 1303 );
698 1304