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/StripeGateway/Stripe.php +469 -31 1.3.21 → 1.6.6 View file →
@@ -28,9 +28,9 @@
28 28 private $methodSlug = 'stripe';
29 29
30 30 public array $supportedFeatures = ['payment', 'refund', 'webhook', 'custom_payment', 'card_update', 'switch_payment_method' => [
31 31 'supported_gateways' => ['stripe', 'paypal'],
32 - ], 'dispute_handler', 'subscriptions', 'zero_recurring'];
32 + ], 'dispute_handler', 'subscriptions', 'zero_recurring', 'system_subscription', 'manual_subscription', 'verify_vendor_ids'];
33 33
34 34 public BaseGatewaySettings $settings;
35 35
36 36 public function __construct()
@@ -55,9 +55,9 @@
55 55
56 56 public function meta(): array
57 57 {
58 58 return [
59 - 'title' => 'Card',
59 + 'title' => __('Card', 'fluent-cart'),
60 60 'route' => 'stripe',
61 61 'slug' => 'stripe',
62 62 'label' => 'Stripe',
63 63 'admin_title' => 'Stripe',
@@ -64,9 +64,9 @@
64 64 'description' => __("Stripe's payments platform lets you accept credit cards, debit cards, and popular payment methods around the world all with a single integration.", "fluent-cart"),
65 65 'logo' => Vite::getAssetUrl('images/payment-methods/card.svg'),
66 66 'icon' => Vite::getAssetUrl('images/payment-methods/stripe-icon.svg'),
67 67 'status' => $this->settings->get('is_active') === 'yes',
68 - 'brand_color' => '#136196',
68 + 'brand_color' => '#635bff',
69 69 'upcoming' => false,
70 70 'supported_features' => $this->supportedFeatures
71 71 ];
72 72 }
@@ -89,13 +89,72 @@
89 89 'amount' => $chargeAmount,
90 90 'currency' => strtolower($transactionCurrency),
91 91 'description' => $storeName . ' #' . $order->invoice_no, // @todo: We will replace with order summary with item names later
92 92 'customer_email' => $paymentInstance->order->email,
93 - 'success_url' => $this->getSuccessUrl($paymentInstance->transaction),
93 + 'success_url' => $paymentInstance->transaction->getSuccessUrl(),
94 + 'gateway_return_url' => Processor::getOnsiteGatewayReturnUrl($paymentInstance->transaction),
95 + 'trx_hash' => $paymentInstance->transaction->uuid,
94 96 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($paymentInstance->order->uuid)
95 97 );
96 98
97 99 if ($paymentInstance->subscription) {
100 + $subscription = $paymentInstance->subscription;
101 +
102 + // Store-managed mode: charge the first order / renewal invoice one-time.
103 + // No Stripe subscription object, no manual→automatic conversion — the
104 + // invoice engine owns all future renewals.
105 + if ($this->shouldChargeSubscriptionAsOneTime($paymentInstance)) {
106 + // System subscriptions save the payment method for off-session
107 + // auto-charging of future renewal invoices (consent shown at checkout).
108 + if ($subscription->collection_method === 'system') {
109 + // Nothing payable now (free trial): a $0 PaymentIntent is invalid —
110 + // save the card via a SetupIntent instead. The trial-end invoice is
111 + // then charged off-session like any other system renewal. Hosted mode
112 + // never loads Stripe.js/Elements, so it needs a redirect-based
113 + // Checkout Session (mode: setup) instead of a client-side SetupIntent.
114 + if ((int) $paymentInstance->transaction->total <= 0) {
115 + $checkoutMode = $this->settings->get('checkout_mode') ?? 'onsite';
116 + if ($checkoutMode === 'hosted') {
117 + return (new Processor())->handleHostedSetupOnlyCheckout($paymentInstance, $paymentArgs);
118 + }
119 +
120 + return (new Processor())->handleSetupOnlyPayment($paymentInstance, $paymentArgs);
121 + }
122 +
123 + $paymentArgs['setup_future_usage'] = 'off_session';
124 + }
125 +
126 + return (new Processor())->handleSinglePayment($paymentInstance, $paymentArgs);
127 + }
128 +
129 + if ($subscription->collection_method === 'manual') {
130 + $previousPaymentMethod = $subscription->current_payment_method;
131 + $conversionResult = $this->convertManualSubscription($paymentInstance, $paymentArgs);
132 + if (is_wp_error($conversionResult)) {
133 + return $conversionResult;
134 + }
135 +
136 + $result = (new Processor())->handleSubscription($paymentInstance, $paymentArgs);
137 +
138 + if (is_wp_error($result)) {
139 + $subscription->update([
140 + 'collection_method' => 'manual',
141 + 'current_payment_method' => $previousPaymentMethod,
142 + ]);
143 + } else {
144 + $subscription->addLog(
145 + 'Converted to automatic billing',
146 + sprintf('Subscription converted from manual to automatic billing via %s', 'Stripe'),
147 + 'info'
148 + );
149 + do_action('fluent_cart/subscription_converted_to_automatic', [
150 + 'subscription' => $subscription,
151 + 'payment_method' => 'stripe',
152 + ]);
153 + }
154 + return $result;
155 + }
156 +
98 157 return (new Processor())->handleSubscription($paymentInstance, $paymentArgs);
99 158 }
100 159
101 160 return (new Processor())->handleSinglePayment($paymentInstance, $paymentArgs);
@@ -100,8 +159,246 @@
100 159
101 160 return (new Processor())->handleSinglePayment($paymentInstance, $paymentArgs);
102 161 }
103 162
163 + public function convertManualSubscription($paymentInstance, $paymentArgs)
164 + {
165 + $subscription = $paymentInstance->subscription;
166 +
167 + if (!$subscription || $subscription->collection_method !== 'manual') {
168 + return new \WP_Error('invalid_subscription', __('Subscription is not manual or does not exist', 'fluent-cart'));
169 + }
170 +
171 + if (in_array($subscription->status, ['completed'])) {
172 + return new \WP_Error('subscription_invalid_status', __('Cannot convert completed subscriptions', 'fluent-cart'));
173 + }
174 +
175 + $subscription->collection_method = 'automatic';
176 + $subscription->current_payment_method = 'stripe';
177 + $subscription->save();
178 +
179 + return true;
180 + }
181 +
182 + /**
183 + * Stripe can vault a card without charging — the onsite Elements flow uses a
184 + * client-side SetupIntent, hosted mode redirects to a Checkout Session in
185 + * `mode: setup` (Processor::handleHostedSetupOnlyCheckout()).
186 + */
187 + public function supportsSetupWithoutCharge(): bool
188 + {
189 + return true;
190 + }
191 +
192 + /**
193 + * Off-session charge of a system subscription's renewal invoice using the
194 + * stored token. Success flows through confirmPaymentSuccessByCharge so the
195 + * normal renewal-paid path (syncOrderStatuses / handleRenewalPaid) runs.
196 + *
197 + * @param PaymentInstance $paymentInstance
198 + * @param array $args ['attempt' => int]
199 + * @return true|'processing'|\WP_Error true = confirmed; 'processing' = charge
200 + * accepted, webhook will confirm
201 + */
202 + public function chargeRenewal(PaymentInstance $paymentInstance, $args = [])
203 + {
204 + $order = $paymentInstance->order;
205 + $transaction = $paymentInstance->transaction;
206 + $subscription = $paymentInstance->subscription;
207 +
208 + if (!$order || !$transaction || !$subscription) {
209 + return new \WP_Error('invalid_instance', __('Renewal invoice is missing its order, transaction, or subscription.', 'fluent-cart'));
210 + }
211 +
212 + $customerId = $subscription->vendor_customer_id;
213 +
214 + // Token read AT FIRE TIME — never snapshotted. The meta has two shapes in
215 + // the wild: vendor_method_id (confirmation paths) and
216 + // details.payment_method_id (card-switch flow) — accept both.
217 + $paymentMethodMeta = $subscription->getMeta('active_payment_method', []) ?: [];
218 + $token = Arr::get($paymentMethodMeta, 'vendor_method_id') ?: Arr::get($paymentMethodMeta, 'details.payment_method_id');
219 +
220 + if (!$customerId || !$token) {
221 + return new \WP_Error('missing_token', __('No saved payment method is available for this subscription.', 'fluent-cart'));
222 + }
223 +
224 + // The saved customer + payment method were created in the order's Stripe
225 + // mode; charging them requires that mode's secret key. If it is missing
226 + // (e.g. a live-mode order on a store configured with test keys only, common
227 + // on staging clones), fail with a clear message rather than sending an empty
228 + // Authorization header to Stripe.
229 + if ($keyError = $this->guardSecretKeyForMode($order->mode)) {
230 + return $keyError;
231 + }
232 +
233 + $chargeAmount = (int) $transaction->total;
234 + if ($transaction->currency && CurrenciesHelper::isZeroDecimal($transaction->currency)) {
235 + $chargeAmount = (int) round($chargeAmount / 100);
236 + }
237 +
238 + $intentData = [
239 + 'amount' => $chargeAmount,
240 + 'currency' => strtolower($transaction->currency),
241 + 'customer' => $customerId,
242 + 'payment_method' => $token,
243 + 'off_session' => 'true',
244 + 'confirm' => 'true',
245 + 'expand' => ['latest_charge'],
246 + 'metadata' => apply_filters('fluent_cart/payments/stripe_metadata_onetime', [
247 + 'fct_ref_id' => $order->uuid,
248 + 'Name' => $order->customer ? $order->customer->full_name : '',
249 + 'Email' => $order->customer ? $order->customer->email : '',
250 + 'order_reference' => 'fct_order_id_' . $order->id,
251 + ], [
252 + 'order' => $order,
253 + 'transaction' => $transaction
254 + ]),
255 + ];
256 +
257 + $attempt = max(1, (int) Arr::get($args, 'attempt', 1));
258 +
259 + $intent = (new API())->createStripeObject('payment_intents', $intentData, $order->mode, [
260 + 'Idempotency-Key' => 'fct_system_charge_' . $order->uuid . '_' . $attempt,
261 + ]);
262 +
263 + if (is_wp_error($intent)) {
264 + return $intent;
265 + }
266 +
267 + $intentStatus = Arr::get($intent, 'status');
268 +
269 + if ($intentStatus === 'succeeded') {
270 + $transaction->update(['vendor_charge_id' => Arr::get($intent, 'id')]);
271 +
272 + (new Confirmations())->confirmPaymentSuccessByCharge($transaction, [
273 + 'charge' => Arr::get($intent, 'latest_charge', []),
274 + 'intent_id' => Arr::get($intent, 'id'),
275 + ]);
276 +
277 + return true;
278 + }
279 +
280 + if ($intentStatus === 'processing') {
281 + // Charge accepted but still settling (e.g. bank debits) — the webhook
282 + // confirms it; keep the invoice scheduled rather than failing it. The
283 + // distinct return keeps the success contract honest: the service fires
284 + // system_charge_succeeded only once the payment is actually confirmed.
285 + $transaction->update(['vendor_charge_id' => Arr::get($intent, 'id')]);
286 + return 'processing';
287 + }
288 +
289 + // requires_action (off-session SCA challenge), declines, and anything else:
290 + // the customer must pay interactively — surface the gateway's reason.
291 + $failureMessage = Arr::get($intent, 'last_payment_error.message');
292 + if (!$failureMessage) {
293 + $failureMessage = sprintf(
294 + /* translators: %1$s: Stripe payment intent status */
295 + __('Automatic charge could not be completed (status: %1$s).', 'fluent-cart'),
296 + $intentStatus ?: 'unknown'
297 + );
298 + }
299 +
300 + return new \WP_Error('charge_failed', $failureMessage);
301 + }
302 +
303 + /**
304 + * Re-check a processing off-session renewal charge. Recovers missed webhooks:
305 + * a settled intent is confirmed through confirmPaymentSuccessByCharge.
306 + *
307 + * @param PaymentInstance $paymentInstance
308 + * @return true|'processing'|\WP_Error
309 + */
310 + public function reconcileRenewalCharge(PaymentInstance $paymentInstance)
311 + {
312 + $order = $paymentInstance->order;
313 + $transaction = $paymentInstance->transaction;
314 +
315 + if (!$order || !$transaction || !$transaction->vendor_charge_id) {
316 + return new \WP_Error('missing_intent', __('No payment intent is recorded for this renewal order.', 'fluent-cart'));
317 + }
318 +
319 + // A missing key is a configuration problem, not a transient API failure —
320 + // surface it (the transient handling below would otherwise loop forever).
321 + if ($keyError = $this->guardSecretKeyForMode($order->mode)) {
322 + return $keyError;
323 + }
324 +
325 + $intent = (new API())->getStripeObject('payment_intents/' . $transaction->vendor_charge_id, [
326 + 'expand' => ['latest_charge']
327 + ], $order->mode);
328 +
329 + if (is_wp_error($intent)) {
330 + // Transient API failure must not fail a possibly-settled payment —
331 + // report still-processing so the reconciliation loop retries later.
332 + return 'processing';
333 + }
334 +
335 + $intentStatus = Arr::get($intent, 'status');
336 +
337 + if ($intentStatus === 'succeeded') {
338 + (new Confirmations())->confirmPaymentSuccessByCharge($transaction, [
339 + 'charge' => Arr::get($intent, 'latest_charge', []),
340 + 'intent_id' => Arr::get($intent, 'id'),
341 + ]);
342 +
343 + return true;
344 + }
345 +
346 + if ($intentStatus === 'processing') {
347 + return 'processing';
348 + }
349 +
350 + $failureMessage = Arr::get($intent, 'last_payment_error.message');
351 + if (!$failureMessage) {
352 + $failureMessage = sprintf(
353 + /* translators: %1$s: Stripe payment intent status */
354 + __('The pending payment could not be completed (status: %1$s).', 'fluent-cart'),
355 + $intentStatus ?: 'unknown'
356 + );
357 + }
358 +
359 + return new \WP_Error('charge_failed', $failureMessage);
360 + }
361 +
362 + public function syncRemoteTransaction(\FluentCart\App\Models\OrderTransaction $transaction)
363 + {
364 + return (new Confirmations())->syncRemoteTransaction($transaction);
365 + }
366 +
367 + /**
368 + * Ensure the Stripe secret key for the given order mode is configured before an
369 + * off-session charge / reconcile. Returns a clear WP_Error when it is missing —
370 + * otherwise Stripe replies with the opaque "You did not provide an API key"
371 + * message. Null when the key is present.
372 + *
373 + * @param string $mode The order's Stripe mode ('test' | 'live').
374 + * @return \WP_Error|null
375 + */
376 + private function guardSecretKeyForMode($mode)
377 + {
378 + if ((new StripeSettingsBase())->getApiKey($mode ?: 'current')) {
379 + return null;
380 + }
381 +
382 + return new \WP_Error('stripe_missing_api_key', sprintf(
383 + /* translators: %1$s: Stripe mode (test or live) */
384 + __('This subscription was created in %1$s mode, but no Stripe %1$s secret key is configured for this store. Add the matching Stripe keys in Payment Settings to charge the saved payment method.', 'fluent-cart'),
385 + $mode ?: 'current'
386 + ));
387 + }
388 +
389 + private function shouldRenderAsSubscriptionMode($hasSubscription): bool
390 + {
391 + // One-time-charged subscription payments (store-managed mode, or a renewal of
392 + // a store-managed-born subscription) go through handleSinglePayment, so
393 + // Elements must initialize with intent mode `payment`, not `subscription`.
394 + if (\FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutChargesOneTime()) {
395 + return false;
396 + }
397 +
398 + return $hasSubscription;
399 + }
400 +
104 401 public function processRefund($transaction, $amount, $args)
105 402 {
106 403 if (!$amount) {
107 404 return new \WP_Error(
@@ -149,12 +446,29 @@
149 446 ]
150 447 ];
151 448 }
152 449
450 + private function getStripeLocale(): string
451 + {
452 + $parts = explode('_', get_locale());
453 + $lang = strtolower($parts[0]);
454 + $region = isset($parts[1]) ? strtoupper($parts[1]) : '';
455 +
456 + if ($region) {
457 + $full = $lang . '-' . $region;
458 + if (in_array($full, ['en-GB', 'fr-CA', 'zh-HK', 'zh-TW', 'pt-BR', 'es-419'])) {
459 + return $full;
460 + }
461 + }
462 +
463 + return $lang ?: 'auto';
464 + }
465 +
153 466 public function getLocalizeData(): array
154 - {
467 + {
155 468 return [
156 469 'fct_stripe_data' => [
470 + 'locale' => $this->getStripeLocale(),
157 471 'translations' => [
158 472 'Payment module not available to checkout! Please reload again, or contact admin!' => __('Payment module not available to checkout! Please reload again, or contact admin!', 'fluent-cart'),
159 473 'See Errors' => __('See Errors', 'fluent-cart'),
160 474 'Pay Now' => __('Pay Now', 'fluent-cart'),
@@ -167,8 +481,11 @@
167 481 'redirecting for action' => __('redirecting for action', 'fluent-cart'),
168 482 'You will be redirected to Stripe to complete your payment securely.' => __('You will be redirected to Stripe to complete your payment securely.', 'fluent-cart'),
169 483 'Something went wrong' => __('Something went wrong', 'fluent-cart'),
170 484 'Payment confirmation failed' => __('Payment confirmation failed', 'fluent-cart'),
485 + 'Payment failed. Please try again.' => __('Payment failed. Please try again.', 'fluent-cart'),
486 + 'We could not record that failed attempt. Please reload the page before trying again.' => __('We could not record that failed attempt. Please reload the page before trying again.', 'fluent-cart'),
487 + 'We could not verify your payment status. Please do not pay again. Contact the store to check your order status.' => __('We could not verify your payment status. Please do not pay again. Contact the store to check your order status.', 'fluent-cart'),
171 488 ]
172 489 ]
173 490 ];
174 491 }
@@ -178,9 +495,14 @@
178 495 $provider = Arr::get($data, 'provider', 'connect');
179 496 $mode = Arr::get($data, 'payment_mode', 'test');
180 497
181 498 if ('connect' == $provider) {
182 - $data[$mode . '_secret_key'] = Helper::encryptKey(Arr::get($data, $mode . '_secret_key'));
499 + $currentKey = Arr::get($data, $mode . '_secret_key', '');
500 + $oldKey = Arr::get($oldSettings, $mode . '_secret_key', '');
501 +
502 + if ($currentKey !== $oldKey) {
503 + $data[$mode . '_secret_key'] = Helper::encryptKey($currentKey);
504 + }
183 505 }
184 506
185 507 if (Arr::get($data, 'provider') === 'api_keys') {
186 508 $data['test_publishable_key'] = '';
@@ -261,9 +583,9 @@
261 583 }
262 584
263 585 public function fields(): array
264 586 {
265 - $disabled = apply_filters_deprecated('fluent_cart_form_disable_stripe_connect', [false, []], '1.3.16', 'fluent_cart/form_disable_stripe_connect', 'Use fluent_cart/form_disable_stripe_connect instead of fluent_cart_form_disable_stripe_connect.');
587 + $disabled = false;
266 588 $providerValue = apply_filters('fluent_cart/form_disable_stripe_connect', $disabled, []) ? 'api_keys' : 'connect';
267 589
268 590 return array(
269 591 'notice' => [
@@ -303,14 +625,37 @@
303 625 'value' => 'onsite',
304 626 'label' => __('Checkout Mode', 'fluent-cart'),
305 627 'type' => 'radio',
306 628 'options' => [
307 - 'onsite' => __('Embedded checkout (Recommended)', 'fluent-cart'),
308 - 'hosted' => __('Stripe Hosted checkout', 'fluent-cart')
629 + 'onsite' => [
630 + 'label' => __('Embedded checkout (Recommended)', 'fluent-cart'),
631 + 'text' => __('Renders inside your checkout page. Supports all standard card payments with a seamless branded experience.', 'fluent-cart'),
632 + 'icon' => Vite::getAssetUrl('images/bill-line.svg')
633 + ],
634 + 'hosted' => [
635 + 'label' => __('Stripe Hosted Checkout', 'fluent-cart'),
636 + 'text' => __("Redirects to Stripe's hosted page. Required for Bank Transfers and methods not supported in embedded mode.", 'fluent-cart'),
637 + 'icon' => Vite::getAssetUrl('images/external-link-line.svg')
638 + ]
309 639 ],
310 640 'tooltip' => __('Choose between Embedded and Hosted checkout modes. Embedded mode is recommended for most use cases. For Bank transfers, use Hosted mode. (checkout.session.completed webhook event will be triggered only for hosted mode)', 'fluent-cart'),
311 641 'description' => __("Embedded checkout is recommended for most use cases. For Bank transfers , or if any payment methods are not showing up on embedded checkout, try Hosted checkout. ('checkout.session.completed' webhook event will be triggered only for hosted checkout)", 'fluent-cart')
312 642 ),
643 + 'submit_type' => array(
644 + 'value' => 'auto',
645 + 'label' => __('Submit Button Label', 'fluent-cart'),
646 + 'type' => 'select',
647 + 'filterable' => false,
648 + 'options' => [
649 + ['value' => 'auto', 'label' => __('Automatic (Pay / Subscribe)', 'fluent-cart')],
650 + ['value' => 'pay', 'label' => __('Pay', 'fluent-cart')],
651 + ['value' => 'book', 'label' => __('Book', 'fluent-cart')],
652 + ['value' => 'donate', 'label' => __('Donate', 'fluent-cart')],
653 + ['value' => 'subscribe', 'label' => __('Subscribe', 'fluent-cart')],
654 + ],
655 + 'tooltip' => __('Stripe customises the submit button and surrounding copy from this. Applies to Stripe Hosted Checkout only.', 'fluent-cart'),
656 + 'description' => __('Applies to Stripe Hosted Checkout only, and is ignored when a card is being saved without a charge. Automatic gives one-time orders the "Buy" button and subscriptions the "Subscribe" button.', 'fluent-cart')
657 + ),
313 658 'webhook_desc' => array(
314 659 'value' => Webhook::webhookInstruction(),
315 660 'label' => __('Webhook URL', 'fluent-cart'),
316 661 'type' => 'html_attr'
@@ -384,8 +729,28 @@
384 729 * @param array $appearance The appearance configuration
385 730 * @return array The modified appearance configuration
386 731 */
387 732 if (($this->settings->get('checkout_mode') ?? 'onsite') == 'hosted') {
733 + // Same off-session consent contract as onsite checkout — the hosted
734 + // Checkout Session vaults the card via setup_future_usage too, so the
735 + // customer must see the same authorization copy before redirecting.
736 + $hasSubscription = $this->validateSubscriptions($this->getCheckoutItems());
737 + $systemConsent = '';
738 + $consentRequired = false;
739 + if (!$this->shouldRenderAsSubscriptionMode($hasSubscription)
740 + && \FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutIsSystem($this)
741 + ) {
742 + $systemConsent = __('Your payment method will be saved securely and charged automatically on each renewal date. You can update or replace it any time from your account.', 'fluent-cart');
743 +
744 + // Zero-payable (free trial): handleSetupOnlyPayment REQUIRES
745 + // _fct_system_consent=yes — same gate as the onsite setup-mode path.
746 + $cart = CartHelper::getCart();
747 + $payableNow = \FluentCart\App\Services\OrderService::getItemsAmountTotal($cart->cart_data ?? [], false, false);
748 + if ($payableNow <= 0) {
749 + $consentRequired = true;
750 + }
751 + }
752 +
388 753 wp_send_json(
389 754 [
390 755 'status' => 'success',
391 756 'message' => __('Order info retrieved!', 'fluent-cart'),
@@ -392,8 +757,10 @@
392 757 'data' => [],
393 758 'payment_args' => [
394 759 'checkout_mode' => 'hosted'
395 760 ],
761 + 'system_consent' => $systemConsent,
762 + 'consent_required' => $consentRequired,
396 763 ],
397 764 200
398 765 );
399 766 }
@@ -404,10 +771,22 @@
404 771 $shippingCharge = Arr::get($shippingChargeData, 'charge');
405 772 $totalPrice = $checkOutHelper->getItemsAmountTotal(false) + $shippingCharge;
406 773
407 774 $tax = $checkOutHelper->getCart()->checkout_data['tax_data'] ?? [];
408 - if (Arr::get($tax, 'tax_behavior', 0) == 1) {
409 - $totalPrice = $totalPrice + Arr::get($tax, 'tax_total', 0) + Arr::get($tax, 'shipping_tax', 0);
775 + $taxBehavior = (int) Arr::get($tax, 'tax_behavior', 0);
776 + $storeTaxBehavior = (int) Arr::get($tax, 'store_tax_behavior', $taxBehavior);
777 +
778 + if ($taxBehavior === 1) {
779 + // Pure exclusive — add all tax including fee tax (tax_total contains both).
780 + $totalPrice = $totalPrice + (int) Arr::get($tax, 'tax_total', 0)
781 + + (int) Arr::get($tax, 'shipping_tax', 0);
782 + } elseif ($taxBehavior === 3) {
783 + // Mixed — add only exclusive product tax + fee/shipping if store is exclusive.
784 + $totalPrice = $totalPrice + (int) Arr::get($tax, 'exclusive_tax_total', 0);
785 + if ($storeTaxBehavior === 1) {
786 + $totalPrice = $totalPrice + (int) Arr::get($tax, 'fee_tax', 0)
787 + + (int) Arr::get($tax, 'shipping_tax', 0);
788 + }
410 789 }
411 790
412 791 $items = $this->getCheckoutItems();
413 792
@@ -416,24 +795,24 @@
416 795 $stripeSettings = new StripeSettingsBase();
417 796 $publicKey = $stripeSettings->getPublicKey();
418 797
419 798 if (empty($publicKey)) {
420 - $message = __('No valid public key found!', 'fluent-cart');
421 - fluent_cart_add_log('Stripe Credential Validation', $message, 'error', ['log_type' => 'payment']);
799 + fluent_cart_add_log(
800 + 'Stripe Credential Validation',
801 + sprintf('Stripe %s keys are missing or invalid.', $stripeSettings->getMode()),
802 + 'error',
803 + ['log_type' => 'payment']
804 + );
422 805 wp_send_json([
423 806 'status' => 'failed',
424 - 'message' => $message
425 - ], 423);
807 + 'message' => __('No valid public key found! Please contact the site administrator.', 'fluent-cart')
808 + ], 422);
426 809 }
427 810
428 811 $paymentArgs['public_key'] = $publicKey;
812 + $appearance = $this->getElementsAppearance();
813 + $fonts = $this->getElementsFonts();
429 814
430 - // Allow filtering the appearance configuration for Stripe Elements
431 - $appearance = apply_filters_deprecated('fluent_cart_stripe_appearance', [
432 - ['theme' => 'stripe']
433 - ], '1.3.16', 'fluent_cart/stripe_appearance', 'Use fluent_cart/stripe_appearance instead of fluent_cart_stripe_appearance.');
434 - $appearance = apply_filters('fluent_cart/stripe_appearance', $appearance);
435 -
436 815 $storeCurrency = CurrencySettings::get('currency');
437 816 $intentAmount = (int)$totalPrice;
438 817
439 818 if ($storeCurrency && CurrenciesHelper::isZeroDecimal($storeCurrency)) {
@@ -446,28 +825,87 @@
446 825 'currency' => strtolower($storeCurrency),
447 826 'automatic_payment_methods' => ['enabled' => true]
448 827 ];
449 828
450 - if ($hasSubscription) {
829 + // Determine if we should render as subscription mode
830 + // This considers global mode, auto-convert, and fallback settings
831 + $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
832 +
833 + // System (auto-charged, store-billed) checkout: one-time payment intent that
834 + // also stores an off-session mandate, plus the save-and-auto-charge consent
835 + // notice rendered under the payment element.
836 + $systemConsent = '';
837 + $consentRequired = false;
838 + if (!$renderAsSubscription && \FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutIsSystem($this)) {
839 + $intentData['setup_future_usage'] = 'off_session';
840 + $systemConsent = __('Your payment method will be saved securely and charged automatically on each renewal date. You can update or replace it any time from your account.', 'fluent-cart');
841 +
842 + // Nothing payable now (free trial): Elements must initialize in SETUP
843 + // mode — a zero-amount payment mode is invalid — and consent becomes a
844 + // required checkbox: without a saved card the trial can never bill.
845 + $payableNow = \FluentCart\App\Services\OrderService::getItemsAmountTotal($cart->cart_data ?? [], false, false);
846 + if ($payableNow <= 0) {
847 + $intentData = [
848 + 'mode' => 'setup',
849 + 'currency' => strtolower($storeCurrency),
850 + ];
851 + $consentRequired = true;
852 + }
853 + }
854 +
855 + if ($renderAsSubscription) {
451 856 $intentData['mode'] = 'subscription';
452 857 $intentData['setup_future_usage'] = 'off_session';
453 - } elseif (Arr::get($data, 'save_payment_method') === 'yes') {
858 + } elseif (empty($intentData['setup_future_usage']) && Arr::get($data, 'save_payment_method') === 'yes') {
454 859 $intentData['setup_future_usage'] = 'on_session';
455 860 }
456 861
862 + // The browser cannot pass setup_future_usage per-request (this endpoint
863 + // receives no body), so extensions that vault cards (e.g. saved payment
864 + // methods) resolve it here, server-side. Must be matched on the actual
865 + // PaymentIntent at place-order (fluent_cart/payments/stripe_onetime_intent_args)
866 + // or Stripe rejects the confirmation for a setup_future_usage mismatch.
867 + $setupFutureUsage = apply_filters(
868 + 'fluent_cart/stripe/client_setup_future_usage',
869 + Arr::get($intentData, 'setup_future_usage'),
870 + ['data' => $data, 'has_subscription' => $hasSubscription]
871 + );
872 + if ($setupFutureUsage) {
873 + $intentData['setup_future_usage'] = $setupFutureUsage;
874 + } else {
875 + unset($intentData['setup_future_usage']);
876 + }
877 +
457 878 wp_send_json(
458 879 [
459 - 'status' => 'success',
460 - 'message' => __('Order info retrieved!', 'fluent-cart'),
461 - 'data' => [],
462 - 'payment_args' => $paymentArgs,
463 - 'intent' => $intentData,
464 - 'appearance' => $appearance,
880 + 'status' => 'success',
881 + 'message' => __('Order info retrieved!', 'fluent-cart'),
882 + 'data' => [],
883 + 'payment_args' => $paymentArgs,
884 + 'intent' => $intentData,
885 + 'appearance' => $appearance,
886 + 'fonts' => $fonts,
887 + 'system_consent' => $systemConsent,
888 + 'consent_required' => $consentRequired,
465 889 ],
466 890 200
467 891 );
468 892 }
469 893
894 + public function getElementsAppearance(): array
895 + {
896 + $appearance = ['theme' => 'stripe'];
897 +
898 + return (array) apply_filters('fluent_cart/stripe_appearance', $appearance);
899 + }
900 +
901 + public function getElementsFonts(): array
902 + {
903 + $fonts = (array) apply_filters('fluent_cart/stripe_elements_fonts', []);
904 +
905 + return array_values(array_filter($fonts, 'is_array'));
906 + }
907 +
470 908 public function getConnectInfo(): array
471 909 {
472 910 return ConnectConfig::getConnectConfig();
473 911 }
@@ -489,9 +927,9 @@
489 927 public function acceptRemoteDispute($transaction, $args = [])
490 928 {
491 929 $disputeId = Arr::get($transaction->meta, 'dispute_id');
492 930 if (!$disputeId) {
493 - $charge = (new API())->getStripeObject('payment_intents/' . $transaction->vendor_charge_id, ['expand' => ['latest_charge']]);
931 + $charge = (new API())->getStripeObject('payment_intents/' . $transaction->vendor_charge_id, ['expand' => ['latest_charge']], $transaction->payment_mode);
494 932
495 933 if (is_wp_error($charge) || empty($charge['dispute'])) {
496 934 new \WP_Error('No dispute ID found!', __('Please check stripe if the dispute is already accepted or not!', 'fluent-cart'));
497 935 }
@@ -498,9 +936,9 @@
498 936
499 937 $disputeId = Arr::get($charge, 'dispute', '');
500 938 }
501 939
502 - $closeDispute = (new API())->createStripeObject('disputes/' . $disputeId . '/close');
940 + $closeDispute = (new API())->createStripeObject('disputes/' . $disputeId . '/close', [], $transaction->payment_mode);
503 941
504 942 if (is_wp_error($closeDispute)) {
505 943 return $closeDispute;
506 944 }