PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.1
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 trunk All 48 releases
fluent-cart / app / Modules / PaymentMethods / StripeGateway / Stripe.php

Stripe.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.1, at app/Modules/PaymentMethods/StripeGateway/Stripe.php

925 lines 39.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\StripeGateway;
4
5 use FluentCart\Api\CurrencySettings;
6 use FluentCart\Api\Orders;
7 use FluentCart\Api\StoreSettings;
8 use FluentCart\App\Helpers\CartCheckoutHelper;
9 use FluentCart\App\Helpers\CartHelper;
10 use FluentCart\App\Helpers\CurrenciesHelper;
11 use FluentCart\App\Helpers\Helper;
12 use FluentCart\App\Hooks\Cart\WebCheckoutHandler;
13 use FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway;
14 use FluentCart\App\Modules\PaymentMethods\Core\BaseGatewaySettings;
15 use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API;
16 use FluentCart\App\Modules\PaymentMethods\StripeGateway\Connect\ConnectConfig;
17 use FluentCart\App\Modules\PaymentMethods\StripeGateway\Webhook\IPN;
18 use FluentCart\App\Modules\PaymentMethods\StripeGateway\Webhook\Webhook;
19 use FluentCart\App\Services\CustomPayment\PaymentIntent;
20 use FluentCart\App\Services\Payments\PaymentHelper;
21 use FluentCart\App\Services\Payments\PaymentInstance;
22 use FluentCart\App\Vite;
23 use FluentCart\Framework\Support\Arr;
24
25 class Stripe extends AbstractPaymentGateway
26 {
27
28 private $methodSlug = 'stripe';
29
30 public array $supportedFeatures = ['payment', 'refund', 'webhook', 'custom_payment', 'card_update', 'switch_payment_method' => [
31 'supported_gateways' => ['stripe', 'paypal'],
32 ], 'dispute_handler', 'subscriptions', 'zero_recurring', 'system_subscription', 'manual_subscription'];
33
34 public BaseGatewaySettings $settings;
35
36 public function __construct()
37 {
38 parent::__construct(
39 new StripeSettingsBase(),
40 new StripeSubscriptions()
41 );
42
43 add_action('fluent_cart_action_stripe_connect', function ($data) {
44 ConnectConfig::handleConnect($data);
45 });
46
47 }
48
49 public function boot()
50 {
51 (new IPN)->init();
52 (new Confirmations)->init();
53 add_filter('fluent_cart/payment_methods/stripe_pub_key', [$this, 'getPublicKey'], 10);
54 }
55
56 public function meta(): array
57 {
58 return [
59 'title' => 'Card',
60 'route' => 'stripe',
61 'slug' => 'stripe',
62 'label' => 'Stripe',
63 'admin_title' => 'Stripe',
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 'logo' => Vite::getAssetUrl('images/payment-methods/card.svg'),
66 'icon' => Vite::getAssetUrl('images/payment-methods/stripe-icon.svg'),
67 'status' => $this->settings->get('is_active') === 'yes',
68 'brand_color' => '#635bff',
69 'upcoming' => false,
70 'supported_features' => $this->supportedFeatures
71 ];
72 }
73
74 public function makePaymentFromPaymentInstance(PaymentInstance $paymentInstance)
75 {
76 $order = $paymentInstance->order;
77
78 $storeName = (new StoreSettings())->get('store_name');
79
80 $transactionCurrency = $paymentInstance->transaction->currency;
81 $chargeAmount = (int)$paymentInstance->transaction->total;
82
83 if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
84 $chargeAmount = (int)round($chargeAmount / 100);
85 }
86
87 $paymentArgs = array(
88 'client_reference_id' => $order->uuid,
89 'amount' => $chargeAmount,
90 'currency' => strtolower($transactionCurrency),
91 'description' => $storeName . ' #' . $order->invoice_no, // @todo: We will replace with order summary with item names later
92 'customer_email' => $paymentInstance->order->email,
93 'success_url' => $this->getSuccessUrl($paymentInstance->transaction),
94 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($paymentInstance->order->uuid)
95 );
96
97 if ($paymentInstance->subscription) {
98 $subscription = $paymentInstance->subscription;
99
100 // Store-managed mode: charge the first order / renewal invoice one-time.
101 // No Stripe subscription object, no manual→automatic conversion — the
102 // invoice engine owns all future renewals.
103 if ($this->shouldChargeSubscriptionAsOneTime($paymentInstance)) {
104 // System subscriptions save the payment method for off-session
105 // auto-charging of future renewal invoices (consent shown at checkout).
106 if ($subscription->collection_method === 'system') {
107 // Nothing payable now (free trial): a $0 PaymentIntent is invalid —
108 // save the card via a SetupIntent instead. The trial-end invoice is
109 // then charged off-session like any other system renewal. Hosted mode
110 // never loads Stripe.js/Elements, so it needs a redirect-based
111 // Checkout Session (mode: setup) instead of a client-side SetupIntent.
112 if ((int) $paymentInstance->transaction->total <= 0) {
113 $checkoutMode = $this->settings->get('checkout_mode') ?? 'onsite';
114 if ($checkoutMode === 'hosted') {
115 return (new Processor())->handleHostedSetupOnlyCheckout($paymentInstance, $paymentArgs);
116 }
117
118 return (new Processor())->handleSetupOnlyPayment($paymentInstance, $paymentArgs);
119 }
120
121 $paymentArgs['setup_future_usage'] = 'off_session';
122 }
123
124 return (new Processor())->handleSinglePayment($paymentInstance, $paymentArgs);
125 }
126
127 if ($subscription->collection_method === 'manual') {
128 $previousPaymentMethod = $subscription->current_payment_method;
129 $conversionResult = $this->convertManualSubscription($paymentInstance, $paymentArgs);
130 if (is_wp_error($conversionResult)) {
131 return $conversionResult;
132 }
133
134 $result = (new Processor())->handleSubscription($paymentInstance, $paymentArgs);
135
136 if (is_wp_error($result)) {
137 $subscription->update([
138 'collection_method' => 'manual',
139 'current_payment_method' => $previousPaymentMethod,
140 ]);
141 } else {
142 $subscription->addLog(
143 'Converted to automatic billing',
144 sprintf('Subscription converted from manual to automatic billing via %s', 'Stripe'),
145 'info'
146 );
147 do_action('fluent_cart/subscription_converted_to_automatic', [
148 'subscription' => $subscription,
149 'payment_method' => 'stripe',
150 ]);
151 }
152 return $result;
153 }
154
155 return (new Processor())->handleSubscription($paymentInstance, $paymentArgs);
156 }
157
158 return (new Processor())->handleSinglePayment($paymentInstance, $paymentArgs);
159 }
160
161 public function convertManualSubscription($paymentInstance, $paymentArgs)
162 {
163 $subscription = $paymentInstance->subscription;
164
165 if (!$subscription || $subscription->collection_method !== 'manual') {
166 return new \WP_Error('invalid_subscription', __('Subscription is not manual or does not exist', 'fluent-cart'));
167 }
168
169 if (in_array($subscription->status, ['completed'])) {
170 return new \WP_Error('subscription_invalid_status', __('Cannot convert completed subscriptions', 'fluent-cart'));
171 }
172
173 $subscription->collection_method = 'automatic';
174 $subscription->current_payment_method = 'stripe';
175 $subscription->save();
176
177 return true;
178 }
179
180 /**
181 * Stripe can vault a card without charging — the onsite Elements flow uses a
182 * client-side SetupIntent, hosted mode redirects to a Checkout Session in
183 * `mode: setup` (Processor::handleHostedSetupOnlyCheckout()).
184 */
185 public function supportsSetupWithoutCharge(): bool
186 {
187 return true;
188 }
189
190 /**
191 * Off-session charge of a system subscription's renewal invoice using the
192 * stored token. Success flows through confirmPaymentSuccessByCharge so the
193 * normal renewal-paid path (syncOrderStatuses / handleRenewalPaid) runs.
194 *
195 * @param PaymentInstance $paymentInstance
196 * @param array $args ['attempt' => int]
197 * @return true|'processing'|\WP_Error true = confirmed; 'processing' = charge
198 * accepted, webhook will confirm
199 */
200 public function chargeRenewal(PaymentInstance $paymentInstance, $args = [])
201 {
202 $order = $paymentInstance->order;
203 $transaction = $paymentInstance->transaction;
204 $subscription = $paymentInstance->subscription;
205
206 if (!$order || !$transaction || !$subscription) {
207 return new \WP_Error('invalid_instance', __('Renewal invoice is missing its order, transaction, or subscription.', 'fluent-cart'));
208 }
209
210 $customerId = $subscription->vendor_customer_id;
211
212 // Token read AT FIRE TIME — never snapshotted. The meta has two shapes in
213 // the wild: vendor_method_id (confirmation paths) and
214 // details.payment_method_id (card-switch flow) — accept both.
215 $paymentMethodMeta = $subscription->getMeta('active_payment_method', []) ?: [];
216 $token = Arr::get($paymentMethodMeta, 'vendor_method_id') ?: Arr::get($paymentMethodMeta, 'details.payment_method_id');
217
218 if (!$customerId || !$token) {
219 return new \WP_Error('missing_token', __('No saved payment method is available for this subscription.', 'fluent-cart'));
220 }
221
222 // The saved customer + payment method were created in the order's Stripe
223 // mode; charging them requires that mode's secret key. If it is missing
224 // (e.g. a live-mode order on a store configured with test keys only, common
225 // on staging clones), fail with a clear message rather than sending an empty
226 // Authorization header to Stripe.
227 if ($keyError = $this->guardSecretKeyForMode($order->mode)) {
228 return $keyError;
229 }
230
231 $chargeAmount = (int) $transaction->total;
232 if ($transaction->currency && CurrenciesHelper::isZeroDecimal($transaction->currency)) {
233 $chargeAmount = (int) round($chargeAmount / 100);
234 }
235
236 $intentData = [
237 'amount' => $chargeAmount,
238 'currency' => strtolower($transaction->currency),
239 'customer' => $customerId,
240 'payment_method' => $token,
241 'off_session' => 'true',
242 'confirm' => 'true',
243 'expand' => ['latest_charge'],
244 'metadata' => apply_filters('fluent_cart/payments/stripe_metadata_onetime', [
245 'fct_ref_id' => $order->uuid,
246 'Name' => $order->customer ? $order->customer->full_name : '',
247 'Email' => $order->customer ? $order->customer->email : '',
248 'order_reference' => 'fct_order_id_' . $order->id,
249 ], [
250 'order' => $order,
251 'transaction' => $transaction
252 ]),
253 ];
254
255 $attempt = max(1, (int) Arr::get($args, 'attempt', 1));
256
257 $intent = (new API())->createStripeObject('payment_intents', $intentData, $order->mode, [
258 'Idempotency-Key' => 'fct_system_charge_' . $order->id . '_' . $attempt,
259 ]);
260
261 if (is_wp_error($intent)) {
262 return $intent;
263 }
264
265 $intentStatus = Arr::get($intent, 'status');
266
267 if ($intentStatus === 'succeeded') {
268 $transaction->update(['vendor_charge_id' => Arr::get($intent, 'id')]);
269
270 (new Confirmations())->confirmPaymentSuccessByCharge($transaction, [
271 'charge' => Arr::get($intent, 'latest_charge', []),
272 'intent_id' => Arr::get($intent, 'id'),
273 ]);
274
275 return true;
276 }
277
278 if ($intentStatus === 'processing') {
279 // Charge accepted but still settling (e.g. bank debits) — the webhook
280 // confirms it; keep the invoice scheduled rather than failing it. The
281 // distinct return keeps the success contract honest: the service fires
282 // system_charge_succeeded only once the payment is actually confirmed.
283 $transaction->update(['vendor_charge_id' => Arr::get($intent, 'id')]);
284 return 'processing';
285 }
286
287 // requires_action (off-session SCA challenge), declines, and anything else:
288 // the customer must pay interactively — surface the gateway's reason.
289 $failureMessage = Arr::get($intent, 'last_payment_error.message');
290 if (!$failureMessage) {
291 $failureMessage = sprintf(
292 /* translators: %1$s: Stripe payment intent status */
293 __('Automatic charge could not be completed (status: %1$s).', 'fluent-cart'),
294 $intentStatus ?: 'unknown'
295 );
296 }
297
298 return new \WP_Error('charge_failed', $failureMessage);
299 }
300
301 /**
302 * Re-check a processing off-session renewal charge. Recovers missed webhooks:
303 * a settled intent is confirmed through confirmPaymentSuccessByCharge.
304 *
305 * @param PaymentInstance $paymentInstance
306 * @return true|'processing'|\WP_Error
307 */
308 public function reconcileRenewalCharge(PaymentInstance $paymentInstance)
309 {
310 $order = $paymentInstance->order;
311 $transaction = $paymentInstance->transaction;
312
313 if (!$order || !$transaction || !$transaction->vendor_charge_id) {
314 return new \WP_Error('missing_intent', __('No payment intent is recorded for this renewal order.', 'fluent-cart'));
315 }
316
317 // A missing key is a configuration problem, not a transient API failure —
318 // surface it (the transient handling below would otherwise loop forever).
319 if ($keyError = $this->guardSecretKeyForMode($order->mode)) {
320 return $keyError;
321 }
322
323 $intent = (new API())->getStripeObject('payment_intents/' . $transaction->vendor_charge_id, [
324 'expand' => ['latest_charge']
325 ], $order->mode);
326
327 if (is_wp_error($intent)) {
328 // Transient API failure must not fail a possibly-settled payment —
329 // report still-processing so the reconciliation loop retries later.
330 return 'processing';
331 }
332
333 $intentStatus = Arr::get($intent, 'status');
334
335 if ($intentStatus === 'succeeded') {
336 (new Confirmations())->confirmPaymentSuccessByCharge($transaction, [
337 'charge' => Arr::get($intent, 'latest_charge', []),
338 'intent_id' => Arr::get($intent, 'id'),
339 ]);
340
341 return true;
342 }
343
344 if ($intentStatus === 'processing') {
345 return 'processing';
346 }
347
348 $failureMessage = Arr::get($intent, 'last_payment_error.message');
349 if (!$failureMessage) {
350 $failureMessage = sprintf(
351 /* translators: %1$s: Stripe payment intent status */
352 __('The pending payment could not be completed (status: %1$s).', 'fluent-cart'),
353 $intentStatus ?: 'unknown'
354 );
355 }
356
357 return new \WP_Error('charge_failed', $failureMessage);
358 }
359
360 public function syncRemoteTransaction(\FluentCart\App\Models\OrderTransaction $transaction)
361 {
362 return (new Confirmations())->syncRemoteTransaction($transaction);
363 }
364
365 /**
366 * Ensure the Stripe secret key for the given order mode is configured before an
367 * off-session charge / reconcile. Returns a clear WP_Error when it is missing —
368 * otherwise Stripe replies with the opaque "You did not provide an API key"
369 * message. Null when the key is present.
370 *
371 * @param string $mode The order's Stripe mode ('test' | 'live').
372 * @return \WP_Error|null
373 */
374 private function guardSecretKeyForMode($mode)
375 {
376 if ((new StripeSettingsBase())->getApiKey($mode ?: 'current')) {
377 return null;
378 }
379
380 return new \WP_Error('stripe_missing_api_key', sprintf(
381 /* translators: %1$s: Stripe mode (test or live) */
382 __('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'),
383 $mode ?: 'current'
384 ));
385 }
386
387 private function shouldRenderAsSubscriptionMode($hasSubscription): bool
388 {
389 // One-time-charged subscription payments (store-managed mode, or a renewal of
390 // a store-managed-born subscription) go through handleSinglePayment, so
391 // Elements must initialize with intent mode `payment`, not `subscription`.
392 if (\FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutChargesOneTime()) {
393 return false;
394 }
395
396 return $hasSubscription;
397 }
398
399 public function processRefund($transaction, $amount, $args)
400 {
401 if (!$amount) {
402 return new \WP_Error(
403 'fluent_cart_stripe_refund_error',
404 __('Refund amount is required.', 'fluent-cart')
405 );
406 }
407
408 return \FluentCart\App\Modules\PaymentMethods\StripeGateway\StripeHelper::processRemoteRefund($transaction, $amount, $args);
409 }
410
411 public function webHookPaymentMethodName()
412 {
413 return $this->getMeta('route');
414 }
415
416 public function handleIPN(): void
417 {
418 (new IPN($this))->verifyAndProcess();
419 }
420
421 public function getEnqueueScriptSrc($hasSubscription = 'no'): array
422 {
423 $checkoutMode = $this->settings->get('checkout_mode') ?? 'onsite';
424
425 if ($checkoutMode == 'hosted') {
426 return [
427 [
428 'handle' => 'fluent-cart-checkout-handler-stripe-hosted',
429 'src' => Vite::getEnqueuePath('public/payment-methods/stripe-hosted-checkout.js'),
430 ]
431 ];
432 }
433
434 // For embedded/onsite mode, load Stripe SDK and full handler
435 return [
436 [
437 'handle' => 'fluent-cart-checkout-sdk-stripe',
438 'src' => 'https://js.stripe.com/v3/',
439 ],
440 [
441 'handle' => 'fluent-cart-checkout-handler-stripe',
442 'src' => Vite::getEnqueuePath('public/payment-methods/stripe-checkout.js'),
443 'deps' => ['fluent-cart-checkout-sdk-stripe']
444 ]
445 ];
446 }
447
448 private function getStripeLocale(): string
449 {
450 $parts = explode('_', get_locale());
451 $lang = strtolower($parts[0]);
452 $region = isset($parts[1]) ? strtoupper($parts[1]) : '';
453
454 if ($region) {
455 $full = $lang . '-' . $region;
456 if (in_array($full, ['en-GB', 'fr-CA', 'zh-HK', 'zh-TW', 'pt-BR', 'es-419'])) {
457 return $full;
458 }
459 }
460
461 return $lang ?: 'auto';
462 }
463
464 public function getLocalizeData(): array
465 {
466 return [
467 'fct_stripe_data' => [
468 'locale' => $this->getStripeLocale(),
469 'translations' => [
470 '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'),
471 'See Errors' => __('See Errors', 'fluent-cart'),
472 'Pay Now' => __('Pay Now', 'fluent-cart'),
473 'Place Order' => __('Place Order', 'fluent-cart'),
474 'Card details are not valid!' => __('Card details are not valid!', 'fluent-cart'),
475 'Total amount is not valid, please add some items to cart!' => __('Total amount is not valid, please add some items to cart!', 'fluent-cart'),
476 'An error occurred while parsing the response.' => __('An error occurred while parsing the response.', 'fluent-cart'),
477 'An error occurred while loading the payment method.' => __('An error occurred while loading the payment method.', 'fluent-cart'),
478 'Loading Payment Processor...' => __('Loading Payment Processor...', 'fluent-cart'),
479 'redirecting for action' => __('redirecting for action', 'fluent-cart'),
480 'You will be redirected to Stripe to complete your payment securely.' => __('You will be redirected to Stripe to complete your payment securely.', 'fluent-cart'),
481 'Something went wrong' => __('Something went wrong', 'fluent-cart'),
482 'Payment confirmation failed' => __('Payment confirmation failed', 'fluent-cart'),
483 ]
484 ]
485 ];
486 }
487
488 public static function beforeSettingsUpdate($data, $oldSettings): array
489 {
490 $provider = Arr::get($data, 'provider', 'connect');
491 $mode = Arr::get($data, 'payment_mode', 'test');
492
493 if ('connect' == $provider) {
494 $currentKey = Arr::get($data, $mode . '_secret_key', '');
495 $oldKey = Arr::get($oldSettings, $mode . '_secret_key', '');
496
497 if ($currentKey !== $oldKey) {
498 $data[$mode . '_secret_key'] = Helper::encryptKey($currentKey);
499 }
500 }
501
502 if (Arr::get($data, 'provider') === 'api_keys') {
503 $data['test_publishable_key'] = '';
504 $data['live_publishable_key'] = '';
505 $data['test_secret_key'] = '';
506 $data['live_secret_key'] = '';
507 }
508
509 return $data;
510 }
511
512 public static function validateSettings($data): array
513 {
514 $mode = Arr::get($data, 'payment_mode', 'test');
515 $provider = Arr::get($data, 'provider', 'connect');
516
517 if ($provider === 'api_keys') {
518 if ($mode === 'live') {
519 $sk = defined('FCT_STRIPE_LIVE_SECRET_KEY') ? FCT_STRIPE_LIVE_SECRET_KEY : Arr::get($data, 'live_secret_key');
520 } else {
521 $sk = defined('FCT_STRIPE_TEST_SECRET_KEY') ? FCT_STRIPE_TEST_SECRET_KEY : Arr::get($data, 'test_secret_key');
522 }
523 } else {
524 $sk = $mode === 'live' ? Arr::get($data, 'live_secret_key') : Arr::get($data, 'test_secret_key');
525 if (empty($sk)) {
526 $errorMessage = $mode === 'live' ? __('Stripe not connected in live mode!', 'fluent-cart') : __('Stripe not connected in test mode!', 'fluent-cart');
527 return [
528 'status' => 'failed',
529 'message' => $errorMessage
530 ];
531 } else {
532 return [
533 'status' => 'success',
534 'message' => __('Stripe account already verified!', 'fluent-cart')
535 ];
536 }
537 }
538
539 if (empty($sk)) {
540 return [
541 'status' => 'failed',
542 'message' => __('Please provide a valid secret key!', 'fluent-cart')
543 ];
544 }
545
546 if ($mode === 'live' && !str_contains($sk, 'sk_live')) {
547 return [
548 'status' => 'failed',
549 'message' => __('Please provide a valid LIVE secret key!', 'fluent-cart')
550 ];
551 } else if ($mode === 'test' && !str_contains($sk, 'sk_test')) {
552 return [
553 'status' => 'failed',
554 'message' => __('Please provide a valid TEST secret key!', 'fluent-cart')
555 ];
556 }
557
558 $response = (new API)->remoteRequest('account', [], $sk, 'GET');
559
560 if (isset($response['error'])) {
561 return [
562 'status' => 'failed',
563 'message' => $response['error']['message'] ? $response['error']['message'] : __('Invalid credentials!', 'fluent-cart')
564 ];
565 }
566
567 if (!isset($response['id'])) {
568 return [
569 'status' => 'failed',
570 'message' => $response['error']['message'] ? $response['error']['message'] : __('Invalid credentials!', 'fluent-cart')
571 ];
572 }
573
574 return [
575 'status' => 'success',
576 'message' => __('Stripe account verified!', 'fluent-cart')
577 ];
578 }
579
580 public function fields(): array
581 {
582 $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. It will be removed in v1.4.3.');
583 $providerValue = apply_filters('fluent_cart/form_disable_stripe_connect', $disabled, []) ? 'api_keys' : 'connect';
584
585 return array(
586 'notice' => [
587 'value' => $this->renderStoreModeNotice(),
588 'label' => __('Store Mode notice', 'fluent-cart'),
589 'type' => 'notice'
590 ],
591 'payment_mode' => [
592 'type' => 'tabs',
593 'schema' => [
594 [
595 'type' => 'tab',
596 'label' => __('Live credentials', 'fluent-cart'),
597 'value' => 'live',
598 'schema' => []
599 ],
600 [
601 'type' => 'tab',
602 'label' => __('Test credentials', 'fluent-cart'),
603 'value' => 'test',
604 'schema' => [],
605 ]
606 ]
607 ],
608 'provider' => array(
609 'value' => $providerValue,
610 'label' => __('Provider', 'fluent-cart'),
611 'type' => 'provider'
612 ),
613 'setup_guide' => array(
614 'value' => '<h4>' . __('Or Setup keys manually.', 'fluent-cart') . '</h4><hr/>',
615 'label' => __('Or Setup keys manually', 'fluent-cart'),
616 'type' => 'html_attr',
617 'visible' => 'no'
618 ),
619 'checkout_mode' => array(
620 'value' => 'onsite',
621 'label' => __('Checkout Mode', 'fluent-cart'),
622 'type' => 'radio',
623 'options' => [
624 'onsite' => [
625 'label' => __('Embedded checkout (Recommended)', 'fluent-cart'),
626 'text' => __('Renders inside your checkout page. Supports all standard card payments with a seamless branded experience.', 'fluent-cart'),
627 'icon' => Vite::getAssetUrl('images/bill-line.svg')
628 ],
629 'hosted' => [
630 'label' => __('Stripe Hosted Checkout', 'fluent-cart'),
631 'text' => __("Redirects to Stripe's hosted page. Required for Bank Transfers and methods not supported in embedded mode.", 'fluent-cart'),
632 'icon' => Vite::getAssetUrl('images/external-link-line.svg')
633 ]
634 ],
635 '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'),
636 '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')
637 ),
638 'webhook_desc' => array(
639 'value' => Webhook::webhookInstruction(),
640 'label' => __('Webhook URL', 'fluent-cart'),
641 'type' => 'html_attr'
642 ),
643 'test_active_methods' => [
644 'value' => (new API())->getActivatedPaymentMethodsConfigs('test'),
645 'label' => __('Activated Methods', 'fluent-cart'),
646 'type' => 'active_methods'
647 ],
648 'live_active_methods' => [
649 'value' => (new API())->getActivatedPaymentMethodsConfigs('live'),
650 'label' => __('Activated Methods', 'fluent-cart'),
651 'type' => 'active_methods'
652 ],
653 );
654
655 }
656
657 public function getPublicKey($pre = '')
658 {
659 return $this->settings->getPublicKey();
660 }
661
662 public function getTransactionUrl($url, $data): string
663 {
664 $transaction = Arr::get($data, 'transaction', null);
665 if (!$transaction) {
666 return $url;
667 }
668
669 if ($transaction->transaction_type === 'refund') {
670 return 'https://dashboard.stripe.com/refunds/' . $transaction->vendor_charge_id;
671 }
672
673 return 'https://dashboard.stripe.com/payments/' . $transaction->vendor_charge_id;
674 }
675
676 public function getSubscriptionUrl($url, $data): string
677 {
678 return 'https://dashboard.stripe.com/subscriptions/' . Arr::get($data, 'vendor_subscription_id');
679 }
680
681 public function getOrderInfo($data)
682 {
683 // For hosted mode, we don't need to return intent data as checkout session is created on order placement
684
685 /*
686 * Filter the Stripe Elements appearance configuration
687 *
688 * This filter allows developers to customize the appearance of Stripe Elements.
689 * For example:
690 *
691 * function stripe_appearance($appearance) {
692 * return array(
693 * 'theme' => 'night',
694 * 'labels' => 'floating',
695 * 'variables' => array(
696 * 'colorPrimary' => '#0570de',
697 * 'colorBackground' => '#ffffff',
698 * 'colorText' => '#30313d',
699 * 'colorDanger' => '#df1b41',
700 * 'fontFamily' => 'Ideal Sans, system-ui, sans-serif',
701 * 'spacingUnit' => '2px',
702 * 'borderRadius' => '4px',
703 * )
704 * );
705 * }
706 * add_filter('fluent_cart/stripe_appearance', 'stripe_appearance', 10, 1);
707 *
708 * @see https://docs.stripe.com/elements/appearance-api for all available options
709 * @param array $appearance The appearance configuration
710 * @return array The modified appearance configuration
711 */
712 if (($this->settings->get('checkout_mode') ?? 'onsite') == 'hosted') {
713 // Same off-session consent contract as onsite checkout — the hosted
714 // Checkout Session vaults the card via setup_future_usage too, so the
715 // customer must see the same authorization copy before redirecting.
716 $hasSubscription = $this->validateSubscriptions($this->getCheckoutItems());
717 $systemConsent = '';
718 $consentRequired = false;
719 if (!$this->shouldRenderAsSubscriptionMode($hasSubscription)
720 && \FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutIsSystem($this)
721 ) {
722 $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');
723
724 // Zero-payable (free trial): handleSetupOnlyPayment REQUIRES
725 // _fct_system_consent=yes — same gate as the onsite setup-mode path.
726 $cart = CartHelper::getCart();
727 $payableNow = \FluentCart\App\Services\OrderService::getItemsAmountTotal($cart->cart_data ?? [], false, false);
728 if ($payableNow <= 0) {
729 $consentRequired = true;
730 }
731 }
732
733 wp_send_json(
734 [
735 'status' => 'success',
736 'message' => __('Order info retrieved!', 'fluent-cart'),
737 'data' => [],
738 'payment_args' => [
739 'checkout_mode' => 'hosted'
740 ],
741 'system_consent' => $systemConsent,
742 'consent_required' => $consentRequired,
743 ],
744 200
745 );
746 }
747
748 $cart = CartHelper::getCart();
749 $checkOutHelper = CartCheckoutHelper::make();
750 $shippingChargeData = (new WebCheckoutHandler())->getShippingChargeData($cart);
751 $shippingCharge = Arr::get($shippingChargeData, 'charge');
752 $totalPrice = $checkOutHelper->getItemsAmountTotal(false) + $shippingCharge;
753
754 $tax = $checkOutHelper->getCart()->checkout_data['tax_data'] ?? [];
755 $taxBehavior = (int) Arr::get($tax, 'tax_behavior', 0);
756 $storeTaxBehavior = (int) Arr::get($tax, 'store_tax_behavior', $taxBehavior);
757
758 if ($taxBehavior === 1) {
759 // Pure exclusive — add all tax including fee tax (tax_total contains both).
760 $totalPrice = $totalPrice + (int) Arr::get($tax, 'tax_total', 0)
761 + (int) Arr::get($tax, 'shipping_tax', 0);
762 } elseif ($taxBehavior === 3) {
763 // Mixed — add only exclusive product tax + fee/shipping if store is exclusive.
764 $totalPrice = $totalPrice + (int) Arr::get($tax, 'exclusive_tax_total', 0);
765 if ($storeTaxBehavior === 1) {
766 $totalPrice = $totalPrice + (int) Arr::get($tax, 'fee_tax', 0)
767 + (int) Arr::get($tax, 'shipping_tax', 0);
768 }
769 }
770
771 $items = $this->getCheckoutItems();
772
773 $hasSubscription = $this->validateSubscriptions($items);
774
775 $stripeSettings = new StripeSettingsBase();
776 $publicKey = $stripeSettings->getPublicKey();
777
778 if (empty($publicKey)) {
779 fluent_cart_add_log(
780 'Stripe Credential Validation',
781 sprintf('Stripe %s keys are missing or invalid.', $stripeSettings->getMode()),
782 'error',
783 ['log_type' => 'payment']
784 );
785 wp_send_json([
786 'status' => 'failed',
787 'message' => __('No valid public key found! Please contact the site administrator.', 'fluent-cart')
788 ], 422);
789 }
790
791 $paymentArgs['public_key'] = $publicKey;
792
793 // Allow filtering the appearance configuration for Stripe Elements
794 $appearance = apply_filters_deprecated('fluent_cart_stripe_appearance', [
795 ['theme' => 'stripe']
796 ], '1.3.16', 'fluent_cart/stripe_appearance', 'Use fluent_cart/stripe_appearance instead of fluent_cart_stripe_appearance. It will be removed in v1.4.3.');
797 $appearance = apply_filters('fluent_cart/stripe_appearance', $appearance);
798
799 $storeCurrency = CurrencySettings::get('currency');
800 $intentAmount = (int)$totalPrice;
801
802 if ($storeCurrency && CurrenciesHelper::isZeroDecimal($storeCurrency)) {
803 $intentAmount = (int)($intentAmount / 100);
804 }
805
806 $intentData = [
807 'mode' => 'payment',
808 'amount' => $intentAmount,
809 'currency' => strtolower($storeCurrency),
810 'automatic_payment_methods' => ['enabled' => true]
811 ];
812
813 // Determine if we should render as subscription mode
814 // This considers global mode, auto-convert, and fallback settings
815 $renderAsSubscription = $this->shouldRenderAsSubscriptionMode($hasSubscription);
816
817 // System (auto-charged, store-billed) checkout: one-time payment intent that
818 // also stores an off-session mandate, plus the save-and-auto-charge consent
819 // notice rendered under the payment element.
820 $systemConsent = '';
821 $consentRequired = false;
822 if (!$renderAsSubscription && \FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::currentCheckoutIsSystem($this)) {
823 $intentData['setup_future_usage'] = 'off_session';
824 $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');
825
826 // Nothing payable now (free trial): Elements must initialize in SETUP
827 // mode — a zero-amount payment mode is invalid — and consent becomes a
828 // required checkbox: without a saved card the trial can never bill.
829 $payableNow = \FluentCart\App\Services\OrderService::getItemsAmountTotal($cart->cart_data ?? [], false, false);
830 if ($payableNow <= 0) {
831 $intentData = [
832 'mode' => 'setup',
833 'currency' => strtolower($storeCurrency),
834 ];
835 $consentRequired = true;
836 }
837 }
838
839 if ($renderAsSubscription) {
840 $intentData['mode'] = 'subscription';
841 $intentData['setup_future_usage'] = 'off_session';
842 } elseif (empty($intentData['setup_future_usage']) && Arr::get($data, 'save_payment_method') === 'yes') {
843 $intentData['setup_future_usage'] = 'on_session';
844 }
845
846 // The browser cannot pass setup_future_usage per-request (this endpoint
847 // receives no body), so extensions that vault cards (e.g. saved payment
848 // methods) resolve it here, server-side. Must be matched on the actual
849 // PaymentIntent at place-order (fluent_cart/payments/stripe_onetime_intent_args)
850 // or Stripe rejects the confirmation for a setup_future_usage mismatch.
851 $setupFutureUsage = apply_filters(
852 'fluent_cart/stripe/client_setup_future_usage',
853 Arr::get($intentData, 'setup_future_usage'),
854 ['data' => $data, 'has_subscription' => $hasSubscription]
855 );
856 if ($setupFutureUsage) {
857 $intentData['setup_future_usage'] = $setupFutureUsage;
858 } else {
859 unset($intentData['setup_future_usage']);
860 }
861
862 wp_send_json(
863 [
864 'status' => 'success',
865 'message' => __('Order info retrieved!', 'fluent-cart'),
866 'data' => [],
867 'payment_args' => $paymentArgs,
868 'intent' => $intentData,
869 'appearance' => $appearance,
870 'system_consent' => $systemConsent,
871 'consent_required' => $consentRequired,
872 ],
873 200
874 );
875 }
876
877 public function getConnectInfo(): array
878 {
879 return ConnectConfig::getConnectConfig();
880 }
881
882 public function disconnect($mode): bool
883 {
884 return ConnectConfig::disconnect($mode);
885 }
886
887 public function getCounterDisputeUrl($transaction)
888 {
889 if (!$transaction->meta['dispute_id']) {
890 return '';
891 }
892
893 return 'https://dashboard.stripe.com/disputes/' . $transaction->meta['dispute_id'];
894 }
895
896 public function acceptRemoteDispute($transaction, $args = [])
897 {
898 $disputeId = Arr::get($transaction->meta, 'dispute_id');
899 if (!$disputeId) {
900 $charge = (new API())->getStripeObject('payment_intents/' . $transaction->vendor_charge_id, ['expand' => ['latest_charge']]);
901
902 if (is_wp_error($charge) || empty($charge['dispute'])) {
903 new \WP_Error('No dispute ID found!', __('Please check stripe if the dispute is already accepted or not!', 'fluent-cart'));
904 }
905
906 $disputeId = Arr::get($charge, 'dispute', '');
907 }
908
909 $closeDispute = (new API())->createStripeObject('disputes/' . $disputeId . '/close');
910
911 if (is_wp_error($closeDispute)) {
912 return $closeDispute;
913 }
914
915 return $closeDispute;
916 }
917
918 public function isCurrencySupported(): bool
919 {
920 // stripe support all listed currencies except for IRR (Iranian Rial)
921 return strtoupper(CurrencySettings::get('currency')) !== 'IRR';
922 }
923
924 }
925