PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.2
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.2
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.2, at app/Modules/PaymentMethods/StripeGateway/Stripe.php

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