PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 All 51 releases
← All changes | app/Modules/PaymentMethods/PayPalGateway/PayPal.php +450 -48 1.5.5 → 1.7.1 View file →
@@ -2,9 +2,8 @@
2 2
3 3 namespace FluentCart\App\Modules\PaymentMethods\PayPalGateway;
4 4
5 5 use FluentCart\Api\CurrencySettings;
6 -use FluentCart\Api\Orders;
7 6 use FluentCart\App\App;
8 7 use FluentCart\App\Helpers\CartCheckoutHelper;
9 8 use FluentCart\App\Helpers\CartHelper;
10 9 use FluentCart\App\Helpers\Helper;
@@ -25,11 +24,15 @@
25 24 private $methodSlug = 'paypal';
26 25
27 26 public array $supportedFeatures = ['payment', 'refund', 'webhook', 'custom_payment', 'card_update', 'switch_payment_method' => [
28 27 'supported_gateways' => ['stripe', 'paypal'],
29 - ], 'dispute_handler', 'subscriptions'];
28 + ], 'dispute_handler', 'subscriptions', 'resume_subscription', 'system_subscription', 'manual_subscription', 'verify_vendor_ids'];
30 29
30 + private $vaultUserIdToken = '';
31 31
32 + private $vaultSetupUnavailable = false;
33 +
34 +
32 35 public function __construct()
33 36 {
34 37 parent::__construct(
35 38 new PayPalSettingsBase(),
@@ -62,8 +65,10 @@
62 65 public function boot()
63 66 {
64 67 (new IPN())->init();
65 68
69 + add_action('fluent_cart_action_paypal_connect', [ConnectConfig::class, 'handleConnect']);
70 +
66 71 add_action('wp_ajax_nopriv_fluent_cart_confirm_paypal_payment', [$this, 'confirmPayPalSinglePayment']);
67 72 add_action('wp_ajax_fluent_cart_confirm_paypal_payment', [$this, 'confirmPayPalSinglePayment']);
68 73
69 74 add_action('wp_ajax_nopriv_fluent_cart_confirm_paypal_subscription', [$this, 'confirmPayPalSubscription']);
@@ -68,8 +73,11 @@
68 73
69 74 add_action('wp_ajax_nopriv_fluent_cart_confirm_paypal_subscription', [$this, 'confirmPayPalSubscription']);
70 75 add_action('wp_ajax_fluent_cart_confirm_paypal_subscription', [$this, 'confirmPayPalSubscription']);
71 76
77 + add_action('wp_ajax_nopriv_fluent_cart_confirm_paypal_vault_setup', [$this, 'confirmPayPalVaultSetup']);
78 + add_action('wp_ajax_fluent_cart_confirm_paypal_vault_setup', [$this, 'confirmPayPalVaultSetup']);
79 +
72 80 add_filter('fluent_cart/payment_methods/paypal_client_id', [$this, 'getClientId'], 10, 2);
73 81
74 82 // add PayPal partner tags
75 83 add_filter('script_loader_tag', function ($tag, $handle) {
@@ -77,8 +85,17 @@
77 85 $tag = str_replace(
78 86 '<script ',
79 87 '<script data-partner-attribution-id="FLUENTCART_SP_PPCP" ', $tag
80 88 );
89 +
90 + // The vault setup-token (save-without-purchase) buttons flow
91 + // requires a browser-safe id token on the SDK script tag.
92 + if ($this->vaultUserIdToken) {
93 + $tag = str_replace(
94 + '<script ',
95 + '<script data-user-id-token="' . esc_attr($this->vaultUserIdToken) . '" ', $tag
96 + );
97 + }
81 98 }
82 99 return $tag;
83 100 }, 1, 2);
84 101
@@ -86,8 +103,62 @@
86 103
87 104 public function makePaymentFromPaymentInstance(PaymentInstance $paymentInstance)
88 105 {
89 106 if ($paymentInstance->subscription) {
107 + $subscription = $paymentInstance->subscription;
108 +
109 + // Store-managed mode: charge the first order / renewal invoice one-time.
110 + // No PayPal billing agreement, no manual→automatic conversion — the
111 + // invoice engine owns all future renewals.
112 + if ($this->shouldChargeSubscriptionAsOneTime($paymentInstance)) {
113 + $paymentArgs = [];
114 +
115 + // System subscriptions vault the buyer's PayPal account during this
116 + // purchase (save-on-success) so future renewal invoices can be
117 + // charged merchant-initiated. The disclosure is shown at checkout
118 + // and PayPal's own approval UI carries the save agreement.
119 + if ($subscription->collection_method === 'system') {
120 + // Nothing payable now (free trial): a $0 PayPal order is invalid —
121 + // vault via a Vault v3 setup token instead (no purchase).
122 + if ((int) $paymentInstance->transaction->total <= 0) {
123 + return (new Processor())->handleSetupOnlyPayment($paymentInstance);
124 + }
125 +
126 + $paymentArgs['vault_on_success'] = true;
127 + }
128 +
129 + return (new Processor())->handleSinglePayment($paymentInstance, $paymentArgs);
130 + }
131 +
132 + if ($subscription->collection_method === 'manual') {
133 + $previousPaymentMethod = $subscription->current_payment_method;
134 + $conversionResult = $this->convertManualSubscription($subscription);
135 + if (is_wp_error($conversionResult)) {
136 + return $conversionResult;
137 + }
138 +
139 + $result = (new Processor())->handleSubscriptionPaymentFromPaymentInstance($paymentInstance, []);
140 +
141 + if (is_wp_error($result)) {
142 + $subscription->update([
143 + 'collection_method' => 'manual',
144 + 'current_payment_method' => $previousPaymentMethod,
145 + ]);
146 + } else {
147 + $subscription->addLog(
148 + 'Converted to automatic billing',
149 + sprintf('Subscription converted from manual to automatic billing via %s', 'PayPal'),
150 + 'info'
151 + );
152 + do_action('fluent_cart/subscription_converted_to_automatic', [
153 + 'subscription' => $subscription,
154 + 'payment_method' => 'paypal',
155 + ]);
156 + }
157 +
158 + return $result;
159 + }
160 +
90 161 return (new Processor())->handleSubscriptionPaymentFromPaymentInstance($paymentInstance, []);
91 162 }
92 163
93 164 return (new Processor())->handleSinglePayment($paymentInstance, []);
@@ -92,8 +163,128 @@
92 163
93 164 return (new Processor())->handleSinglePayment($paymentInstance, []);
94 165 }
95 166
167 + public function convertManualSubscription($subscription)
168 + {
169 + if (!$subscription || $subscription->collection_method !== 'manual') {
170 + return new \WP_Error('invalid_subscription', __('Subscription is not manual or does not exist', 'fluent-cart'));
171 + }
172 +
173 + if (in_array($subscription->status, ['completed'])) {
174 + return new \WP_Error('subscription_invalid_status', __('Cannot convert completed subscriptions', 'fluent-cart'));
175 + }
176 +
177 + $subscription->collection_method = 'automatic';
178 + $subscription->current_payment_method = 'paypal';
179 + $subscription->save();
180 +
181 + return true;
182 + }
183 +
184 + private function shouldRenderAsSubscriptionMode($hasSubscription): bool
185 + {
186 + // One-time-charged subscription payments (store-managed mode, or a renewal of
187 + // a store-managed-born subscription) go through handleSinglePayment, so the
188 + // PayPal SDK must load with intent=capture (no vault) and getOrderInfo must
189 + // report payment mode, not subscription mode.
190 + if (\FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutChargesOneTime()) {
191 + return false;
192 + }
193 +
194 + return $hasSubscription;
195 + }
196 +
197 + /**
198 + * PayPal can vault a wallet without charging (Vault v3 setup tokens) — but
199 + * only the smart-buttons flow implements it; other checkout modes keep the
200 + * pre-feature behavior (gateway hidden for zero-payable system carts).
201 + */
202 + public function supportsSetupWithoutCharge(): bool
203 + {
204 + return $this->settings->get('checkout_mode') === 'paypal_pro';
205 + }
206 +
207 + /**
208 + * Zero-payable system checkout on this page load: the SDK must carry a
209 + * user id token and getOrderInfo must report setup mode.
210 + */
211 + private function isZeroPayableSetupCheckout($hasSubscription): bool
212 + {
213 + if (!$hasSubscription || $this->shouldRenderAsSubscriptionMode($hasSubscription)) {
214 + return false;
215 + }
216 +
217 + if (!\FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutIsSystem($this)) {
218 + return false;
219 + }
220 +
221 + return CartHelper::getCart() && $this->getPayableNowTotal() <= 0;
222 + }
223 +
224 + /**
225 + * Amount payable on THIS checkout (items + shipping + additive taxes) — the
226 + * same total the charge transaction is created with. Every frontend
227 + * zero-payable decision must predict transaction->total with this computation.
228 + */
229 + private function getPayableNowTotal(): int
230 + {
231 + $checkOutHelper = CartCheckoutHelper::make();
232 + $shippingChargeData = (new WebCheckoutHandler())->getShippingChargeData(CartHelper::getCart());
233 + $shippingCharge = Arr::get($shippingChargeData, 'charge');
234 + $totalPrice = $checkOutHelper->getItemsAmountTotal(false) + $shippingCharge;
235 +
236 + $tax = $checkOutHelper->getCart()->checkout_data['tax_data'] ?? [];
237 + $taxBehavior = (int) Arr::get($tax, 'tax_behavior', 0);
238 + $storeTaxBehavior = (int) Arr::get($tax, 'store_tax_behavior', $taxBehavior);
239 +
240 + if ($taxBehavior === 1) {
241 + // Pure exclusive — add all tax including fee tax (tax_total contains both).
242 + $totalPrice = $totalPrice + (int) Arr::get($tax, 'tax_total', 0)
243 + + (int) Arr::get($tax, 'shipping_tax', 0);
244 + } elseif ($taxBehavior === 3) {
245 + // Mixed — add only exclusive product tax + fee/shipping if store is exclusive.
246 + $totalPrice = $totalPrice + (int) Arr::get($tax, 'exclusive_tax_total', 0);
247 + if ($storeTaxBehavior === 1) {
248 + $totalPrice = $totalPrice + (int) Arr::get($tax, 'fee_tax', 0)
249 + + (int) Arr::get($tax, 'shipping_tax', 0);
250 + }
251 + }
252 +
253 + return (int) $totalPrice;
254 + }
255 +
256 + /**
257 + * Off-session charge of a system subscription's renewal invoice against the
258 + * vaulted PayPal token. Contract per
259 + * dev-docs/system-subscriptions/gateway-implementation-guide.md.
260 + *
261 + * @param PaymentInstance $paymentInstance
262 + * @param array $args ['attempt' => int]
263 + * @return true|string|\WP_Error true = confirmed; 'processing' = accepted,
264 + * settling (webhook/reconciler will confirm)
265 + */
266 + public function chargeRenewal(PaymentInstance $paymentInstance, $args = [])
267 + {
268 + return (new Processor())->chargeVaultedRenewal($paymentInstance, $args);
269 + }
270 +
271 + /**
272 + * Re-check a processing vault charge (lost webhook / slow eCheck).
273 + *
274 + * @param PaymentInstance $paymentInstance
275 + * @return true|string|\WP_Error
276 + */
277 + public function reconcileRenewalCharge(PaymentInstance $paymentInstance)
278 + {
279 + return (new Processor())->reconcileVaultedRenewal($paymentInstance);
280 + }
281 +
282 + public function syncRemoteTransaction(\FluentCart\App\Models\OrderTransaction $transaction)
283 + {
284 + return (new Processor())->syncRemoteTransaction($transaction);
285 + }
286 +
96 287 public function confirmPayPalSinglePayment()
97 288 {
98 289 if (empty(App::request()->get('payId')) || empty(App::request()->get('ref_id'))) {
99 290 wp_send_json([
@@ -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 {
@@ -684,8 +1060,15 @@
684 1060 'schema' => $testSchema
685 1061 ]
686 1062 ]
687 1063 ],
1064 + 'brand_name' => [
1065 + 'value' => '',
1066 + 'label' => __('Brand name', 'fluent-cart'),
1067 + 'placeholder' => __('Shown on the PayPal checkout instead of your business name', 'fluent-cart'),
1068 + 'tooltip' => __('Optional. Up to 127 characters. Leave empty to show the name on your PayPal business account.', 'fluent-cart'),
1069 + 'type' => 'text',
1070 + ],
688 1071 'provider' => array(
689 1072 'value' => $this->settings->getProviderType(),
690 1073 'label' => __('Provider', 'fluent-cart'),
691 1074 'type' => 'provider'
@@ -779,9 +1162,9 @@
779 1162 {
780 1163 return null;
781 1164 }
782 1165
783 - public function getEnqueueScriptSrc($hasSubscription = 'no'): array
1166 + public function getEnqueueScriptSrc($hasSubscription = false): array
784 1167 {
785 1168 if ($this->settings->get('checkout_mode') !== 'paypal_pro') {
786 1169 return [];
787 1170 }
@@ -790,12 +1173,28 @@
790 1173 $clientId = sanitize_text_field($clientId);
791 1174
792 1175 $sdkSrc = 'https://www.paypal.com/sdk/js?client-id=' . $clientId;
793 1176
794 - if ('yes' == $hasSubscription) {
1177 + $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1178 +
1179 + if ($renderAsSubscription) {
795 1180 $sdkSrc = add_query_arg(array('vault' => 'true', 'intent' => 'subscription'), $sdkSrc);
796 1181 } else {
797 1182 $sdkSrc = add_query_arg(array('currency' => strtoupper(CurrencySettings::get('currency')), 'intent' => 'capture'), $sdkSrc);
1183 +
1184 + if ($this->isZeroPayableSetupCheckout($hasSubscription)) {
1185 + $idToken = API::getUserIdToken();
1186 + if (!is_wp_error($idToken) && $idToken) {
1187 + $this->vaultUserIdToken = $idToken;
1188 + } else {
1189 + // The vault buttons cannot start without the SDK id token —
1190 + // tell the checkout JS to show an error, not a dead button.
1191 + $this->vaultSetupUnavailable = true;
1192 + if (is_wp_error($idToken)) {
1193 + fluent_cart_add_log('PayPal Vault Setup', $idToken->get_error_message(), 'error', ['log_type' => 'payment']);
1194 + }
1195 + }
1196 + }
798 1197 }
799 1198 $sdkSrc = apply_filters('fluent_cart/payments/paypal_sdk_src', $sdkSrc, []);
800 1199
801 1200 return [
@@ -814,9 +1213,11 @@
814 1213 public function getLocalizeData(): array
815 1214 {
816 1215 return [
817 1216 'fct_paypal_data' => [
1217 + 'vault_setup_unavailable' => $this->vaultSetupUnavailable ? 'yes' : 'no',
818 1218 'translations' => [
1219 + 'PayPal is temporarily unavailable for this checkout. Please choose another payment method or try again later.' => __('PayPal is temporarily unavailable for this checkout. Please choose another payment method or try again later.', 'fluent-cart'),
819 1220 'uuid not found' => __('uuid not found', 'fluent-cart'),
820 1221 'Choose any option to continue' => __('Choose any option to continue', 'fluent-cart'),
821 1222 'An unknown error occurred' => __('An unknown error occurred', 'fluent-cart'),
822 1223 'An error occurred while loading PayPal.' => __('An error occurred while loading PayPal.', 'fluent-cart'),
@@ -826,8 +1227,9 @@
826 1227 'No Subscription ID' => __('No Subscription ID', 'fluent-cart'),
827 1228 'no processing' => __('no processing', 'fluent-cart'),
828 1229 'not proper order handler' => __('not proper order handler', 'fluent-cart'),
829 1230 'Payment confirmation failed' => __('Payment confirmation failed', 'fluent-cart'),
1231 + 'Your payment is being reviewed. We will confirm your order once it completes.' => __('Your payment is being reviewed. We will confirm your order once it completes.', 'fluent-cart'),
830 1232 ]
831 1233 ]
832 1234 ];
833 1235 }
@@ -845,31 +1247,11 @@
845 1247 }
846 1248
847 1249 public function getOrderInfo($data)
848 1250 {
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;
1251 + $checkOutHelper = CartCheckoutHelper::make();
1252 + $totalPrice = $this->getPayableNowTotal();
854 1253
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 1254 $items = $checkOutHelper->getItems();
873 1255 $hasSubscription = $this->validateSubscriptions($items);
874 1256
875 1257 $clientId = $this->settings->getPublicKey();
@@ -884,26 +1266,46 @@
884 1266 }
885 1267
886 1268 $paymentArgs['public_key'] = $clientId;
887 1269
1270 + $currency = strtoupper(CurrencySettings::get('currency'));
1271 +
888 1272 $paymentDetails = [
889 1273 'mode' => 'payment',
890 - 'amount' => number_format(Helper::toDecimalWithoutComma($totalPrice), 2, '.', ''),
891 - 'currency' => strtoupper(CurrencySettings::get('currency')),
1274 + 'amount' => PayPalHelper::formatAmount($totalPrice, $currency),
1275 + 'currency' => $currency,
892 1276 ];
893 1277
894 - if ($hasSubscription) {
1278 + $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
1279 +
1280 + if ($renderAsSubscription) {
895 1281 $paymentDetails['mode'] = 'subscription';
896 1282 }
897 1283
1284 + // System (auto-charged, store-billed) checkout: the buyer's PayPal account
1285 + // is vaulted during the purchase — disclose the save-and-auto-charge next
1286 + // to the PayPal button (PayPal's approval popup carries the agreement too).
1287 + $systemConsent = '';
1288 + if (!$renderAsSubscription && \FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutIsSystem($this)) {
1289 + $systemConsent = __('Your PayPal account will be saved securely and charged automatically on each renewal date. You can cancel any time from your account.', 'fluent-cart');
1290 +
1291 + // Nothing payable now (free trial): buttons render the vault
1292 + // setup-token flow. The disclosure stays informational — PayPal's
1293 + // approval popup itself carries the explicit save agreement.
1294 + if ($totalPrice <= 0) {
1295 + $paymentDetails['mode'] = 'setup';
1296 + }
1297 + }
1298 +
898 1299 $this->checkCurrencySupport();
899 1300
900 1301 wp_send_json(
901 1302 [
902 - 'data' => [],
903 - 'payment_args' => $paymentArgs,
904 - 'message' => __('Order info retrieved!', 'fluent-cart'),
905 - 'intent' => $paymentDetails,
1303 + 'data' => [],
1304 + 'payment_args' => $paymentArgs,
1305 + 'message' => __('Order info retrieved!', 'fluent-cart'),
1306 + 'intent' => $paymentDetails,
1307 + 'system_consent' => $systemConsent,
906 1308 ],
907 1309 200
908 1310 );
909 1311