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
fluent-cart / app / Modules / PaymentMethods / StripeGateway / Stripe.php

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

956 lines 41.5 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', 'verify_vendor_ids'];
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', 'fluent-cart'),
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' => $paymentInstance->transaction->getSuccessUrl(),
94 'gateway_return_url' => Processor::getOnsiteGatewayReturnUrl($paymentInstance->transaction),
95 'trx_hash' => $paymentInstance->transaction->uuid,
96 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($paymentInstance->order->uuid)
97 );
98
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
157 return (new Processor())->handleSubscription($paymentInstance, $paymentArgs);
158 }
159
160 return (new Processor())->handleSinglePayment($paymentInstance, $paymentArgs);
161 }
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
401 public function processRefund($transaction, $amount, $args)
402 {
403 if (!$amount) {
404 return new \WP_Error(
405 'fluent_cart_stripe_refund_error',
406 __('Refund amount is required.', 'fluent-cart')
407 );
408 }
409
410 return \FluentCart\App\Modules\PaymentMethods\StripeGateway\StripeHelper::processRemoteRefund($transaction, $amount, $args);
411 }
412
413 public function webHookPaymentMethodName()
414 {
415 return $this->getMeta('route');
416 }
417
418 public function handleIPN(): void
419 {
420 (new IPN($this))->verifyAndProcess();
421 }
422
423 public function getEnqueueScriptSrc($hasSubscription = 'no'): array
424 {
425 $checkoutMode = $this->settings->get('checkout_mode') ?? 'onsite';
426
427 if ($checkoutMode == 'hosted') {
428 return [
429 [
430 'handle' => 'fluent-cart-checkout-handler-stripe-hosted',
431 'src' => Vite::getEnqueuePath('public/payment-methods/stripe-hosted-checkout.js'),
432 ]
433 ];
434 }
435
436 // For embedded/onsite mode, load Stripe SDK and full handler
437 return [
438 [
439 'handle' => 'fluent-cart-checkout-sdk-stripe',
440 'src' => 'https://js.stripe.com/v3/',
441 ],
442 [
443 'handle' => 'fluent-cart-checkout-handler-stripe',
444 'src' => Vite::getEnqueuePath('public/payment-methods/stripe-checkout.js'),
445 'deps' => ['fluent-cart-checkout-sdk-stripe']
446 ]
447 ];
448 }
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
466 public function getLocalizeData(): array
467 {
468 return [
469 'fct_stripe_data' => [
470 'locale' => $this->getStripeLocale(),
471 'translations' => [
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'),
473 'See Errors' => __('See Errors', 'fluent-cart'),
474 'Pay Now' => __('Pay Now', 'fluent-cart'),
475 'Place Order' => __('Place Order', 'fluent-cart'),
476 'Card details are not valid!' => __('Card details are not valid!', 'fluent-cart'),
477 'Total amount is not valid, please add some items to cart!' => __('Total amount is not valid, please add some items to cart!', 'fluent-cart'),
478 'An error occurred while parsing the response.' => __('An error occurred while parsing the response.', 'fluent-cart'),
479 'An error occurred while loading the payment method.' => __('An error occurred while loading the payment method.', 'fluent-cart'),
480 'Loading Payment Processor...' => __('Loading Payment Processor...', 'fluent-cart'),
481 'redirecting for action' => __('redirecting for action', 'fluent-cart'),
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'),
483 'Something went wrong' => __('Something went wrong', 'fluent-cart'),
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'),
488 ]
489 ]
490 ];
491 }
492
493 public static function beforeSettingsUpdate($data, $oldSettings): array
494 {
495 $provider = Arr::get($data, 'provider', 'connect');
496 $mode = Arr::get($data, 'payment_mode', 'test');
497
498 if ('connect' == $provider) {
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 }
505 }
506
507 if (Arr::get($data, 'provider') === 'api_keys') {
508 $data['test_publishable_key'] = '';
509 $data['live_publishable_key'] = '';
510 $data['test_secret_key'] = '';
511 $data['live_secret_key'] = '';
512 }
513
514 return $data;
515 }
516
517 public static function validateSettings($data): array
518 {
519 $mode = Arr::get($data, 'payment_mode', 'test');
520 $provider = Arr::get($data, 'provider', 'connect');
521
522 if ($provider === 'api_keys') {
523 if ($mode === 'live') {
524 $sk = defined('FCT_STRIPE_LIVE_SECRET_KEY') ? FCT_STRIPE_LIVE_SECRET_KEY : Arr::get($data, 'live_secret_key');
525 } else {
526 $sk = defined('FCT_STRIPE_TEST_SECRET_KEY') ? FCT_STRIPE_TEST_SECRET_KEY : Arr::get($data, 'test_secret_key');
527 }
528 } else {
529 $sk = $mode === 'live' ? Arr::get($data, 'live_secret_key') : Arr::get($data, 'test_secret_key');
530 if (empty($sk)) {
531 $errorMessage = $mode === 'live' ? __('Stripe not connected in live mode!', 'fluent-cart') : __('Stripe not connected in test mode!', 'fluent-cart');
532 return [
533 'status' => 'failed',
534 'message' => $errorMessage
535 ];
536 } else {
537 return [
538 'status' => 'success',
539 'message' => __('Stripe account already verified!', 'fluent-cart')
540 ];
541 }
542 }
543
544 if (empty($sk)) {
545 return [
546 'status' => 'failed',
547 'message' => __('Please provide a valid secret key!', 'fluent-cart')
548 ];
549 }
550
551 if ($mode === 'live' && !str_contains($sk, 'sk_live')) {
552 return [
553 'status' => 'failed',
554 'message' => __('Please provide a valid LIVE secret key!', 'fluent-cart')
555 ];
556 } else if ($mode === 'test' && !str_contains($sk, 'sk_test')) {
557 return [
558 'status' => 'failed',
559 'message' => __('Please provide a valid TEST secret key!', 'fluent-cart')
560 ];
561 }
562
563 $response = (new API)->remoteRequest('account', [], $sk, 'GET');
564
565 if (isset($response['error'])) {
566 return [
567 'status' => 'failed',
568 'message' => $response['error']['message'] ? $response['error']['message'] : __('Invalid credentials!', 'fluent-cart')
569 ];
570 }
571
572 if (!isset($response['id'])) {
573 return [
574 'status' => 'failed',
575 'message' => $response['error']['message'] ? $response['error']['message'] : __('Invalid credentials!', 'fluent-cart')
576 ];
577 }
578
579 return [
580 'status' => 'success',
581 'message' => __('Stripe account verified!', 'fluent-cart')
582 ];
583 }
584
585 public function fields(): array
586 {
587 $disabled = false;
588 $providerValue = apply_filters('fluent_cart/form_disable_stripe_connect', $disabled, []) ? 'api_keys' : 'connect';
589
590 return array(
591 'notice' => [
592 'value' => $this->renderStoreModeNotice(),
593 'label' => __('Store Mode notice', 'fluent-cart'),
594 'type' => 'notice'
595 ],
596 'payment_mode' => [
597 'type' => 'tabs',
598 'schema' => [
599 [
600 'type' => 'tab',
601 'label' => __('Live credentials', 'fluent-cart'),
602 'value' => 'live',
603 'schema' => []
604 ],
605 [
606 'type' => 'tab',
607 'label' => __('Test credentials', 'fluent-cart'),
608 'value' => 'test',
609 'schema' => [],
610 ]
611 ]
612 ],
613 'provider' => array(
614 'value' => $providerValue,
615 'label' => __('Provider', 'fluent-cart'),
616 'type' => 'provider'
617 ),
618 'setup_guide' => array(
619 'value' => '<h4>' . __('Or Setup keys manually.', 'fluent-cart') . '</h4><hr/>',
620 'label' => __('Or Setup keys manually', 'fluent-cart'),
621 'type' => 'html_attr',
622 'visible' => 'no'
623 ),
624 'checkout_mode' => array(
625 'value' => 'onsite',
626 'label' => __('Checkout Mode', 'fluent-cart'),
627 'type' => 'radio',
628 'options' => [
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 ]
639 ],
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'),
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')
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 ),
658 'webhook_desc' => array(
659 'value' => Webhook::webhookInstruction(),
660 'label' => __('Webhook URL', 'fluent-cart'),
661 'type' => 'html_attr'
662 ),
663 'test_active_methods' => [
664 'value' => (new API())->getActivatedPaymentMethodsConfigs('test'),
665 'label' => __('Activated Methods', 'fluent-cart'),
666 'type' => 'active_methods'
667 ],
668 'live_active_methods' => [
669 'value' => (new API())->getActivatedPaymentMethodsConfigs('live'),
670 'label' => __('Activated Methods', 'fluent-cart'),
671 'type' => 'active_methods'
672 ],
673 );
674
675 }
676
677 public function getPublicKey($pre = '')
678 {
679 return $this->settings->getPublicKey();
680 }
681
682 public function getTransactionUrl($url, $data): string
683 {
684 $transaction = Arr::get($data, 'transaction', null);
685 if (!$transaction) {
686 return $url;
687 }
688
689 if ($transaction->transaction_type === 'refund') {
690 return 'https://dashboard.stripe.com/refunds/' . $transaction->vendor_charge_id;
691 }
692
693 return 'https://dashboard.stripe.com/payments/' . $transaction->vendor_charge_id;
694 }
695
696 public function getSubscriptionUrl($url, $data): string
697 {
698 return 'https://dashboard.stripe.com/subscriptions/' . Arr::get($data, 'vendor_subscription_id');
699 }
700
701 public function getOrderInfo($data)
702 {
703 // For hosted mode, we don't need to return intent data as checkout session is created on order placement
704
705 /*
706 * Filter the Stripe Elements appearance configuration
707 *
708 * This filter allows developers to customize the appearance of Stripe Elements.
709 * For example:
710 *
711 * function stripe_appearance($appearance) {
712 * return array(
713 * 'theme' => 'night',
714 * 'labels' => 'floating',
715 * 'variables' => array(
716 * 'colorPrimary' => '#0570de',
717 * 'colorBackground' => '#ffffff',
718 * 'colorText' => '#30313d',
719 * 'colorDanger' => '#df1b41',
720 * 'fontFamily' => 'Ideal Sans, system-ui, sans-serif',
721 * 'spacingUnit' => '2px',
722 * 'borderRadius' => '4px',
723 * )
724 * );
725 * }
726 * add_filter('fluent_cart/stripe_appearance', 'stripe_appearance', 10, 1);
727 *
728 * @see https://docs.stripe.com/elements/appearance-api for all available options
729 * @param array $appearance The appearance configuration
730 * @return array The modified appearance configuration
731 */
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
753 wp_send_json(
754 [
755 'status' => 'success',
756 'message' => __('Order info retrieved!', 'fluent-cart'),
757 'data' => [],
758 'payment_args' => [
759 'checkout_mode' => 'hosted'
760 ],
761 'system_consent' => $systemConsent,
762 'consent_required' => $consentRequired,
763 ],
764 200
765 );
766 }
767
768 $cart = CartHelper::getCart();
769 $checkOutHelper = CartCheckoutHelper::make();
770 $shippingChargeData = (new WebCheckoutHandler())->getShippingChargeData($cart);
771 $shippingCharge = Arr::get($shippingChargeData, 'charge');
772 $totalPrice = $checkOutHelper->getItemsAmountTotal(false) + $shippingCharge;
773
774 $tax = $checkOutHelper->getCart()->checkout_data['tax_data'] ?? [];
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 }
789 }
790
791 $items = $this->getCheckoutItems();
792
793 $hasSubscription = $this->validateSubscriptions($items);
794
795 $stripeSettings = new StripeSettingsBase();
796 $publicKey = $stripeSettings->getPublicKey();
797
798 if (empty($publicKey)) {
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 );
805 wp_send_json([
806 'status' => 'failed',
807 'message' => __('No valid public key found! Please contact the site administrator.', 'fluent-cart')
808 ], 422);
809 }
810
811 $paymentArgs['public_key'] = $publicKey;
812 $appearance = $this->getElementsAppearance();
813 $fonts = $this->getElementsFonts();
814
815 $storeCurrency = CurrencySettings::get('currency');
816 $intentAmount = (int)$totalPrice;
817
818 if ($storeCurrency && CurrenciesHelper::isZeroDecimal($storeCurrency)) {
819 $intentAmount = (int)($intentAmount / 100);
820 }
821
822 $intentData = [
823 'mode' => 'payment',
824 'amount' => $intentAmount,
825 'currency' => strtolower($storeCurrency),
826 'automatic_payment_methods' => ['enabled' => true]
827 ];
828
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) {
856 $intentData['mode'] = 'subscription';
857 $intentData['setup_future_usage'] = 'off_session';
858 } elseif (empty($intentData['setup_future_usage']) && Arr::get($data, 'save_payment_method') === 'yes') {
859 $intentData['setup_future_usage'] = 'on_session';
860 }
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
878 wp_send_json(
879 [
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,
889 ],
890 200
891 );
892 }
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
908 public function getConnectInfo(): array
909 {
910 return ConnectConfig::getConnectConfig();
911 }
912
913 public function disconnect($mode): bool
914 {
915 return ConnectConfig::disconnect($mode);
916 }
917
918 public function getCounterDisputeUrl($transaction)
919 {
920 if (!$transaction->meta['dispute_id']) {
921 return '';
922 }
923
924 return 'https://dashboard.stripe.com/disputes/' . $transaction->meta['dispute_id'];
925 }
926
927 public function acceptRemoteDispute($transaction, $args = [])
928 {
929 $disputeId = Arr::get($transaction->meta, 'dispute_id');
930 if (!$disputeId) {
931 $charge = (new API())->getStripeObject('payment_intents/' . $transaction->vendor_charge_id, ['expand' => ['latest_charge']], $transaction->payment_mode);
932
933 if (is_wp_error($charge) || empty($charge['dispute'])) {
934 new \WP_Error('No dispute ID found!', __('Please check stripe if the dispute is already accepted or not!', 'fluent-cart'));
935 }
936
937 $disputeId = Arr::get($charge, 'dispute', '');
938 }
939
940 $closeDispute = (new API())->createStripeObject('disputes/' . $disputeId . '/close', [], $transaction->payment_mode);
941
942 if (is_wp_error($closeDispute)) {
943 return $closeDispute;
944 }
945
946 return $closeDispute;
947 }
948
949 public function isCurrencySupported(): bool
950 {
951 // stripe support all listed currencies except for IRR (Iranian Rial)
952 return strtoupper(CurrencySettings::get('currency')) !== 'IRR';
953 }
954
955 }
956