PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
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 1.3.20 All 50 releases
← All changes | app/Modules/PaymentMethods/PayPalGateway/PayPal.php +443 -48 1.5.5 → 1.7.0 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([
@@ -204,15 +395,17 @@
204 395 $paidCurrency = strtoupper(Arr::get($unit, 'amount.currency_code', ''));
205 396 }
206 397 }
207 398
208 - if ($paidAmount != $transaction->total) {
399 + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
400 +
401 + if ($paidAmount != $expectedAmount) {
209 402 fluent_cart_warning_log(
210 403 __('PayPal Amount Mismatch Attempt', 'fluent-cart'),
211 404 sprintf(
212 405 /* translators: %1$s: expected amount, %2$s: received amount */
213 406 __('Payment amount mismatch detected. Expected: %1$s, Received: %2$s. This may indicate payment tampering.', 'fluent-cart'),
214 - Helper::toDecimal($transaction->total),
407 + Helper::toDecimal($expectedAmount),
215 408 Helper::toDecimal($paidAmount)
216 409 ),
217 410 [
218 411 'module_name' => 'order',
@@ -250,16 +443,27 @@
250 443 $capture = Arr::get($payment_intent, 'purchase_units.0.payments.captures.0', []);
251 444 $chargeId = Arr::get($capture, 'id', '');
252 445 $captureStatus = Arr::get($capture, 'status', '');
253 446
254 - // A completed order must have a completed capture with a real charge id. A capture
255 - // in PENDING (risk/review hold) has moved no money yet: leave the transaction
256 - // pending and let the PAYMENT.CAPTURE.COMPLETED webhook finalize it. Never mark the
257 - // order paid off an empty charge id or a non-completed capture.
258 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 +
259 459 wp_send_json([
260 - 'status' => 'pending',
261 - 'message' => __('Your payment is being reviewed by PayPal. Your order will be confirmed once the payment is completed.', 'fluent-cart')
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')
262 466 ], 202);
263 467 }
264 468
265 469 if (!$chargeId || $captureStatus !== 'COMPLETED') {
@@ -295,8 +499,12 @@
295 499 'payer' => Arr::get($payment_intent, 'payer', [])
296 500 ],
297 501 'payment_source' => Arr::get($payment_intent, 'payment_source', []),
298 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);
299 507 }
300 508 } finally {
301 509 if ($payPalCaptureLockAcquired) {
302 510 $this->releasePayPalCaptureLock($chargeId);
@@ -311,9 +519,9 @@
311 519 }
312 520
313 521 wp_send_json([
314 522 'status' => 'success',
315 - 'redirect_url' => $transaction->getReceiptPageUrl(true),
523 + 'redirect_url' => $this->getConfirmRedirectUrl($transaction),
316 524 'order' => [
317 525 'uuid' => $transaction->order->uuid
318 526 ],
319 527 'message' => __('Payment has been paid successfully! Redirecting...', 'fluent-cart')
@@ -319,8 +527,93 @@
319 527 'message' => __('Payment has been paid successfully! Redirecting...', 'fluent-cart')
320 528 ]);
321 529 }
322 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 +
323 616 public function confirmPayPalSubscription()
324 617 {
325 618 if (empty(App::request()->get('subscription_id')) || empty(App::request()->get('ref_id'))) {
326 619 wp_send_json([
@@ -426,9 +719,9 @@
426 719
427 720 wp_send_json([
428 721 'status' => 'success',
429 722 'message' => __('Subscription has been activated successfully!', 'fluent-cart'),
430 - 'redirect_url' => $transaction->getReceiptPageUrl(true),
723 + 'redirect_url' => $this->getConfirmRedirectUrl($transaction),
431 724 'order' => [
432 725 'uuid' => $transaction->order->uuid
433 726 ],
434 727 ], 200);
@@ -438,8 +731,27 @@
438 731 {
439 732 return API::getResource('billing/subscriptions/' . $subscriptionId);
440 733 }
441 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 +
442 754 protected function verifyPayPalPayment($payPalReferenceId)
443 755 {
444 756 return API::verifyPayment($payPalReferenceId);
445 757 }
@@ -459,12 +771,8 @@
459 771 * @return bool
460 772 */
461 773 protected function isAlreadyCapturedError($error)
462 774 {
463 - if (!is_wp_error($error)) {
464 - return false;
465 - }
466 -
467 775 if ($error->get_error_code() === 'ORDER_ALREADY_CAPTURED') {
468 776 return true;
469 777 }
470 778
@@ -478,8 +786,76 @@
478 786
479 787 return false;
480 788 }
481 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 +
482 858 protected function hasExistingPayPalCapture(OrderTransaction $transaction, $chargeId)
483 859 {
484 860 return (bool) OrderTransaction::query()
485 861 ->where('vendor_charge_id', $chargeId)
@@ -525,10 +901,10 @@
525 901 if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] != 'POST') {
526 902 return;
527 903 }
528 904
905 + // Sends the HTTP status via status_header() and exits — never returns.
529 906 (new IPN())->processWebhook();
530 - exit(200);
531 907 }
532 908
533 909 public function getTransactionUrl($url, $data)
534 910 {
@@ -779,9 +1155,9 @@
779 1155 {
780 1156 return null;
781 1157 }
782 1158
783 - public function getEnqueueScriptSrc($hasSubscription = 'no'): array
1159 + public function getEnqueueScriptSrc($hasSubscription = false): array
784 1160 {
785 1161 if ($this->settings->get('checkout_mode') !== 'paypal_pro') {
786 1162 return [];
787 1163 }
@@ -790,12 +1166,28 @@
790 1166 $clientId = sanitize_text_field($clientId);
791 1167
792 1168 $sdkSrc = 'https://www.paypal.com/sdk/js?client-id=' . $clientId;
793 1169
794 - if ('yes' == $hasSubscription) {
1170 + $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1171 +
1172 + if ($renderAsSubscription) {
795 1173 $sdkSrc = add_query_arg(array('vault' => 'true', 'intent' => 'subscription'), $sdkSrc);
796 1174 } else {
797 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 + }
798 1190 }
799 1191 $sdkSrc = apply_filters('fluent_cart/payments/paypal_sdk_src', $sdkSrc, []);
800 1192
801 1193 return [
@@ -814,9 +1206,11 @@
814 1206 public function getLocalizeData(): array
815 1207 {
816 1208 return [
817 1209 'fct_paypal_data' => [
1210 + 'vault_setup_unavailable' => $this->vaultSetupUnavailable ? 'yes' : 'no',
818 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'),
819 1213 'uuid not found' => __('uuid not found', 'fluent-cart'),
820 1214 'Choose any option to continue' => __('Choose any option to continue', 'fluent-cart'),
821 1215 'An unknown error occurred' => __('An unknown error occurred', 'fluent-cart'),
822 1216 'An error occurred while loading PayPal.' => __('An error occurred while loading PayPal.', 'fluent-cart'),
@@ -826,8 +1220,9 @@
826 1220 'No Subscription ID' => __('No Subscription ID', 'fluent-cart'),
827 1221 'no processing' => __('no processing', 'fluent-cart'),
828 1222 'not proper order handler' => __('not proper order handler', 'fluent-cart'),
829 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'),
830 1225 ]
831 1226 ]
832 1227 ];
833 1228 }
@@ -845,31 +1240,11 @@
845 1240 }
846 1241
847 1242 public function getOrderInfo($data)
848 1243 {
849 - $cart = CartHelper::getCart();
850 - $checkOutHelper = CartCheckoutHelper::make();
851 - $shippingChargeData = (new WebCheckoutHandler())->getShippingChargeData($cart);
852 - $shippingCharge = Arr::get($shippingChargeData, 'charge');
853 - $totalPrice = $checkOutHelper->getItemsAmountTotal(false) + $shippingCharge;
1244 + $checkOutHelper = CartCheckoutHelper::make();
1245 + $totalPrice = $this->getPayableNowTotal();
854 1246
855 - $tax = $checkOutHelper->getCart()->checkout_data['tax_data'] ?? [];
856 - $taxBehavior = (int) Arr::get($tax, 'tax_behavior', 0);
857 - $storeTaxBehavior = (int) Arr::get($tax, 'store_tax_behavior', $taxBehavior);
858 -
859 - if ($taxBehavior === 1) {
860 - // Pure exclusive — add all tax including fee tax (tax_total contains both).
861 - $totalPrice = $totalPrice + (int) Arr::get($tax, 'tax_total', 0)
862 - + (int) Arr::get($tax, 'shipping_tax', 0);
863 - } elseif ($taxBehavior === 3) {
864 - // Mixed — add only exclusive product tax + fee/shipping if store is exclusive.
865 - $totalPrice = $totalPrice + (int) Arr::get($tax, 'exclusive_tax_total', 0);
866 - if ($storeTaxBehavior === 1) {
867 - $totalPrice = $totalPrice + (int) Arr::get($tax, 'fee_tax', 0)
868 - + (int) Arr::get($tax, 'shipping_tax', 0);
869 - }
870 - }
871 -
872 1247 $items = $checkOutHelper->getItems();
873 1248 $hasSubscription = $this->validateSubscriptions($items);
874 1249
875 1250 $clientId = $this->settings->getPublicKey();
@@ -884,26 +1259,46 @@
884 1259 }
885 1260
886 1261 $paymentArgs['public_key'] = $clientId;
887 1262
1263 + $currency = strtoupper(CurrencySettings::get('currency'));
1264 +
888 1265 $paymentDetails = [
889 1266 'mode' => 'payment',
890 - 'amount' => number_format(Helper::toDecimalWithoutComma($totalPrice), 2, '.', ''),
891 - 'currency' => strtoupper(CurrencySettings::get('currency')),
1267 + 'amount' => PayPalHelper::formatAmount($totalPrice, $currency),
1268 + 'currency' => $currency,
892 1269 ];
893 1270
894 - if ($hasSubscription) {
1271 + $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1272 +
1273 + if ($renderAsSubscription) {
895 1274 $paymentDetails['mode'] = 'subscription';
896 1275 }
897 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 +
898 1292 $this->checkCurrencySupport();
899 1293
900 1294 wp_send_json(
901 1295 [
902 - 'data' => [],
903 - 'payment_args' => $paymentArgs,
904 - 'message' => __('Order info retrieved!', 'fluent-cart'),
905 - 'intent' => $paymentDetails,
1296 + 'data' => [],
1297 + 'payment_args' => $paymentArgs,
1298 + 'message' => __('Order info retrieved!', 'fluent-cart'),
1299 + 'intent' => $paymentDetails,
1300 + 'system_consent' => $systemConsent,
906 1301 ],
907 1302 200
908 1303 );
909 1304