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 / Processor.php

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

1,211 lines 52.1 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\App\App;
6 use FluentCart\App\Helpers\CurrenciesHelper;
7 use FluentCart\App\Models\Cart;
8 use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API;
9 use FluentCart\App\Services\Payments\PaymentInstance;
10 use FluentCart\App\Helpers\Helper;
11 use FluentCart\Framework\Support\Arr;
12
13 class Processor
14 {
15 /**
16 * The return URL handed to Stripe for an onsite confirm.
17 *
18 * Onsite normally never navigates (`redirect: 'if_required'`), but an
19 * issuer that forces a full 3DS redirect sends the buyer here. Deliberately
20 * unfiltered: this is a machine contract dispatched by core WebRoutes to
21 * the fluent_cart_action_fct_stripe_onsite_return action, which confirms
22 * before the buyer is sent anywhere.
23 *
24 * @param \FluentCart\App\Models\OrderTransaction $transaction
25 * @return string
26 */
27 public static function getOnsiteGatewayReturnUrl($transaction)
28 {
29 return site_url('?fluent-cart=fct_stripe_onsite_return&trx_hash=' . $transaction->uuid);
30 }
31
32 /**
33 * The return URL handed to Stripe for hosted checkout sessions.
34 *
35 * Deliberately unfiltered: this URL is a machine contract dispatched by
36 * core WebRoutes to the fluent_cart_action_fct_stripe_hosted action,
37 * which confirms the session. The buyer's real destination —
38 * fluent_cart/payment/success_url — is applied AFTER confirmation, in
39 * the hosted-return redirect.
40 *
41 * @param \FluentCart\App\Models\OrderTransaction $transaction
42 * @return string
43 */
44 public static function getHostedGatewayReturnUrl($transaction)
45 {
46 return site_url('?fluent-cart=fct_stripe_hosted&trx_hash=' . $transaction->uuid);
47 }
48
49 public function handleSubscription(PaymentInstance $paymentInstance, $paymentArgs)
50 {
51 $stripeSettings = new StripeSettingsBase();
52 $checkoutMode = $stripeSettings->get('checkout_mode') ?? 'onsite';
53
54 // If hosted mode, create Checkout Session for subscription
55 if ($checkoutMode === 'hosted') {
56 return $this->handleHostedSubscriptionCheckout($paymentInstance, $paymentArgs);
57 }
58
59 // Original onsite subscription flow
60 $orderType = $paymentInstance->order->type;
61 $fcCustomer = $paymentInstance->order->customer;
62 $billingAddress = $paymentInstance->order->billing_address;
63
64 $subscriptionModel = $paymentInstance->subscription;
65
66 if (!$subscriptionModel) {
67 return new \WP_Error('no_subscription', __('No subscription found.', 'fluent-cart'));
68 }
69
70 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($paymentInstance->order->customer);
71
72 if (is_wp_error($stripeCustomer)) {
73 return $stripeCustomer;
74 }
75
76 $feeTotal = $orderType !== 'renewal' ? (int)$paymentInstance->order->fee_total : 0;
77 $initialAmount = (int)$subscriptionModel->signup_fee + $paymentInstance->getExtraAddonAmount() + $feeTotal;
78
79 if ($orderType == 'renewal') {
80 $stripePlan = Plan::getStripePricing([
81 'order_id' => $subscriptionModel->parent_order_id,
82 'product_id' => $subscriptionModel->product_id,
83 'variation_id' => $subscriptionModel->variation_id,
84 'billing_interval' => $subscriptionModel->billing_interval,
85 'recurring_total' => $subscriptionModel->getCurrentRenewalAmount(),
86 'currency' => $paymentInstance->order->currency,
87 'trial_days' => $subscriptionModel->getReactivationTrialDays(), // No trial for renewals
88 'interval_count' => 1 // per month / year / week
89 ]);
90
91 $initialAmount = 0;
92 } else {
93 $stripePlan = Plan::getStripePricing([
94 'order_id' => $subscriptionModel->parent_order_id,
95 'product_id' => $subscriptionModel->product_id,
96 'variation_id' => $subscriptionModel->variation_id,
97 'billing_interval' => $subscriptionModel->billing_interval,
98 'recurring_total' => $subscriptionModel->recurring_total,
99 'currency' => $paymentInstance->order->currency,
100 'trial_days' => (int)$subscriptionModel->trial_days,
101 'interval_count' => 1 // per month / year / week
102 ]);
103 }
104
105 if (is_wp_error($stripePlan)) {
106 return $stripePlan;
107 }
108
109 $stripeSubscriptionData = [
110 'customer' => Arr::get($stripeCustomer, 'id', ''),
111 'payment_behavior' => 'default_incomplete',
112 'payment_settings' => [
113 'save_default_payment_method' => 'on_subscription'
114 ],
115 'items' => [
116 [
117 'plan' => $stripePlan['id'],
118 'quantity' => $subscriptionModel->quantity ?: 1,
119 ]
120 ],
121 'expand' => [
122 'latest_invoice.confirmation_secret',
123 'pending_setup_intent'
124 ],
125 'metadata' => apply_filters('fluent_cart/payments/stripe_metadata_subscription', [
126 'fct_ref_id' => $paymentInstance->order->uuid,
127 'email' => $paymentInstance->order->customer->email,
128 'name' => $paymentInstance->order->full_name,
129 'subscription_item' => $subscriptionModel->item_name,
130 'order_reference' => 'fct_order_id_' . $paymentInstance->order->id,
131 ], [
132 'order' => $paymentInstance->order,
133 'transaction' => $paymentInstance->transaction,
134 'subscription' => $subscriptionModel
135 ]),
136 ];
137
138 if (Arr::get($stripePlan, 'trial_period_days')) {
139 // Anchor trial_end to a STABLE point (the charge transaction's creation time,
140 // which is preserved across re-submissions) instead of "now". A volatile
141 // trial_end would make a retried create send a different body, and Stripe
142 // rejects a reused Idempotency-Key whose parameters changed (400), bricking
143 // the order for the key's 24h lifetime. Anchoring keeps retries byte-identical.
144 $trialDays = (int) Arr::get($stripePlan, 'trial_period_days');
145 $anchorTs = $paymentInstance->transaction && $paymentInstance->transaction->created_at
146 ? strtotime($paymentInstance->transaction->created_at . ' UTC')
147 : time();
148 $trialEnd = strtotime('+' . $trialDays . ' days', $anchorTs);
149 // Stripe requires trial_end in the future; only a stale late retry could fall
150 // behind, and that order would already carry a fresh transaction/key anyway.
151 if ($trialEnd <= time() + MINUTE_IN_SECONDS) {
152 $trialEnd = strtotime('+' . $trialDays . ' days');
153 }
154 $stripeSubscriptionData['trial_end'] = $trialEnd;
155 }
156
157 // Maybe we have initial amount
158 if ($initialAmount) {
159 $addonPrice = Plan::getOneTimeAddonPrice([
160 'product_id' => $subscriptionModel->product_id,
161 'currency' => $paymentInstance->order->currency,
162 'amount' => (int)$initialAmount,
163 'variation_id' => $subscriptionModel->variation_id,
164 'order_id' => $subscriptionModel->parent_order_id,
165 ]);
166
167 if (is_wp_error($addonPrice)) {
168 return $addonPrice;
169 }
170
171 $stripeSubscriptionData['add_invoice_items'] = [
172 [
173 'price' => $addonPrice['id'],
174 'quantity' => 1
175 ]
176 ];
177 }
178
179 if ($expireAt = $paymentInstance->getSubscriptionCancelAtTimeStamp()) {
180 // $stripeSubscriptionData['cancel_at'] = $expireAt;
181 }
182
183 // Duplicate-charge defense — key construction contract in
184 // .claude/skills/coding-rules/payment-idempotency.md. Seed dedupes duplicates
185 // and frees retries; fingerprint = charge-material params so an edited order
186 // gets a fresh key instead of a same-key/changed-parameters 400 (the abandoned
187 // incomplete subscription auto-expires). Params, not transaction->total: a
188 // recurring coupon can change the plan while the first charge stays $0.
189 // Metadata excluded — volatile filters must not change the key on a duplicate.
190 // Guard runs here, after the create body is built, so it can compare it.
191 $existingRemoteSubscription = $this->guardExistingRemoteSubscription($subscriptionModel, $paymentInstance->order, $stripeSubscriptionData);
192 if (is_wp_error($existingRemoteSubscription)) {
193 return $existingRemoteSubscription;
194 }
195
196 $idempotencyFingerprint = [
197 'customer' => Arr::get($stripeSubscriptionData, 'customer'),
198 'items' => Arr::get($stripeSubscriptionData, 'items'),
199 'add_invoice_items' => Arr::get($stripeSubscriptionData, 'add_invoice_items'),
200 'trial_end' => Arr::get($stripeSubscriptionData, 'trial_end'),
201 'replaces' => (string)Arr::get(
202 (array)$subscriptionModel->config,
203 'stripe_replaced_vendor_sub_id',
204 ''
205 ),
206 ];
207 $idempotencySeed = $paymentInstance->getIdempotencySeed();
208 $idempotencyKey = $idempotencySeed
209 ? 'fct_stripe_sub_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
210 : null;
211
212 if ($existingRemoteSubscription) {
213 $stripeSubscription = $existingRemoteSubscription;
214 } else {
215 $stripeSubscription = (new API())->createStripeObject('subscriptions', $stripeSubscriptionData, 'current', [
216 'Idempotency-Key' => $idempotencyKey
217 ]);
218 }
219
220 if (is_wp_error($stripeSubscription)) {
221 return $stripeSubscription;
222 }
223
224 // A guard-reused sub carries an expanded payment_intent object here.
225 $vendorChargeId = Arr::get($stripeSubscription, 'latest_invoice.payment_intent');
226 if (is_array($vendorChargeId)) {
227 $vendorChargeId = Arr::get($vendorChargeId, 'id');
228 }
229 if (!$vendorChargeId) {
230 $vendorChargeId = Arr::get($stripeSubscription, 'pending_setup_intent.id');
231 }
232
233 if ($vendorChargeId) {
234 $paymentInstance->transaction->update(['vendor_charge_id' => $vendorChargeId]);
235 }
236
237 $vendorSubscriptionId = Arr::get($stripeSubscription, 'id');
238
239 $subscriptionUpdateFields = [
240 'vendor_subscription_id' => $vendorSubscriptionId,
241 'vendor_customer_id' => $stripeSubscription['customer']
242 ];
243
244 $subscriptionModel->update($subscriptionUpdateFields);
245
246 if ($orderType == 'renewal' && Arr::get($stripePlan, 'trial_period_days', 0) > 0) {
247 $subscriptionModel->mergeConfig(['is_trial_days_simulated' => 'yes']);
248 }
249
250 if ($stripeSubscription['pending_setup_intent'] != null) {
251 $paymentArgs['vendor_subscription_info'] = [
252 'type' => 'setup',
253 'clientSecret' => Arr::get($stripeSubscription, 'pending_setup_intent.client_secret'),
254 'trx_hash' => $paymentInstance->transaction->uuid,
255 ];
256 } else {
257 $paymentArgs['vendor_subscription_info'] = [
258 'type' => 'payment',
259 'clientSecret' => Arr::get($stripeSubscription, 'latest_invoice.confirmation_secret.client_secret')
260 ];
261 }
262
263 $customerData = [
264 'name' => $fcCustomer->first_name . ' ' . $fcCustomer->last_name,
265 'email' => $fcCustomer->email,
266 'address_1' => $billingAddress->address_1,
267 'address_2' => $billingAddress->address_2,
268 'city' => $billingAddress->city,
269 'state' => $billingAddress->state,
270 'postcode' => $billingAddress->postcode,
271 'country' => $billingAddress->country
272 ];
273
274 return [
275 'nextAction' => 'stripe',
276 'actionName' => 'custom',
277 'status' => 'success',
278 'message' => __('Order has been placed successfully', 'fluent-cart'),
279 'payment_args' => $paymentArgs,
280 'response' => $stripeSubscription,
281 'fc_customer' => $customerData
282 ];
283 }
284
285 /**
286 * A retryable checkout can arrive with a live Stripe subscription already
287 * attached — the previous create succeeded but its confirm/webhook never
288 * landed, and a changed cart mints a fresh idempotency key, so the key alone
289 * cannot stop a second create. A second create bills the customer on a
290 * subscription the store cannot see or cancel.
291 *
292 * Ownership discriminator: metadata.fct_ref_id (stamped at create) must match
293 * $order->uuid before the guard reuses or cancels anything. A non-matching sub
294 * belongs to another flow — on renewal, the previous cycle's subscription that
295 * SubscriptionRenewalHandler cancels only after payment succeeds — so the
296 * guard must leave it alone. active/trialing blocks regardless of owner: the
297 * customer must never be charged beside a live subscription. One exception:
298 * an owned trialing sub with no payment method and a still-confirmable
299 * pending_setup_intent is handed back for reuse — a $0 first invoice skips
300 * `incomplete`, so a failed card setup leaves the sub trialing, not dead.
301 *
302 * Owned incomplete the buyer can still confirm is handed back for reuse —
303 * `incomplete` is the normal status for the whole confirm (3DS) window, and
304 * cancelling it voids the PaymentIntent mid-confirmation
305 * (payment_intent_unexpected_state). Owned but dead is cancelled, the
306 * cancelled id persisted to subscription config
307 * (stripe_replaced_vendor_sub_id) and the local vendor id cleared; the
308 * caller folds the persisted id into the idempotency fingerprint so the
309 * recreate cannot replay Stripe's 24h-cached response for the deleted sub
310 * (the pending transaction's seed has not rolled). Persisted, not
311 * request-local, so a retry after an ambiguously failed recreate computes
312 * the same key and Stripe's idempotent replay recovers the unrecorded sub.
313 * A failed cancel fails CLOSED, like guardExistingPaymentIntent.
314 *
315 * @param array $requestData create body for reuse comparison; empty (hosted
316 * Checkout Session) means nothing is reusable.
317 * @return array|\WP_Error|null reusable remote sub, stop, or create fresh
318 */
319 private function guardExistingRemoteSubscription($subscriptionModel, $order, $requestData = [])
320 {
321 $existingVendorSubId = $subscriptionModel->vendor_subscription_id;
322 if (!$existingVendorSubId || strpos($existingVendorSubId, 'sub_') !== 0) {
323 return null;
324 }
325
326 $remoteSub = (new API())->getStripeObject('subscriptions/' . $existingVendorSubId, [
327 'expand' => [
328 'latest_invoice.confirmation_secret',
329 'latest_invoice.payment_intent',
330 'pending_setup_intent'
331 ]
332 ], 'current');
333
334 if (is_wp_error($remoteSub)) {
335 return null;
336 }
337
338 $remoteStatus = Arr::get($remoteSub, 'status');
339
340 if (in_array($remoteStatus, ['active', 'trialing'], true)) {
341 // A $0 first invoice skips `incomplete`: the sub is `trialing` while the
342 // card is still being set up via pending_setup_intent, and a failed 3DS
343 // leaves it trialing with no payment method. Hand the setup intent back
344 // so the buyer's retry can attach a card instead of being blocked.
345 if (
346 $remoteStatus === 'trialing'
347 && !Arr::get($remoteSub, 'default_payment_method')
348 && Arr::get($remoteSub, 'metadata.fct_ref_id') === $order->uuid
349 && $this->remoteSubscriptionIsConfirmable($remoteSub, $requestData)
350 ) {
351 return $remoteSub;
352 }
353
354 (new StripeSubscriptions())->reSyncSubscriptionFromRemote($subscriptionModel);
355 return new \WP_Error(
356 'stripe_subscription_already_active',
357 __('Subscription is already active. Please refresh this page to see the status instead of trying again.', 'fluent-cart')
358 );
359 }
360
361 if (Arr::get($remoteSub, 'metadata.fct_ref_id') !== $order->uuid) {
362 return null;
363 }
364
365 // Already canceled remotely (e.g. an earlier guard cancel whose replacement
366 // create failed before it was recorded): mark it replaced and clear the
367 // local id — the retry then recomputes the post-cancel key, and Stripe's
368 // idempotent replay recovers any unrecorded replacement.
369 if (in_array($remoteStatus, ['canceled', 'incomplete_expired'], true)) {
370 $subscriptionModel->mergeConfig(['stripe_replaced_vendor_sub_id' => $existingVendorSubId]);
371 $subscriptionModel->update(['vendor_subscription_id' => '']);
372 return null;
373 }
374
375 if (!in_array($remoteStatus, ['incomplete', 'unpaid'], true)) {
376 return null;
377 }
378
379 if ($remoteStatus === 'incomplete' && $this->remoteSubscriptionIsConfirmable($remoteSub, $requestData)) {
380 return $remoteSub;
381 }
382
383 $cancelResponse = (new API())->deleteStripeObject('subscriptions/' . $existingVendorSubId, [], 'current');
384 if (is_wp_error($cancelResponse)) {
385 fluent_cart_warning_log(
386 'Stripe stale ' . $remoteStatus . ' subscription cancel failed',
387 $cancelResponse->get_error_message() . ' (' . $existingVendorSubId . ')',
388 [
389 'module_type' => 'FluentCart\App\Models\Subscription',
390 'module_id' => $subscriptionModel->id,
391 'module_name' => 'subscription',
392 'log_type' => 'api'
393 ]
394 );
395
396 return new \WP_Error(
397 'stripe_subscription_cancel_failed',
398 __('We could not update your previous subscription attempt. Please wait a moment and try again.', 'fluent-cart')
399 );
400 }
401
402 // Marker before id-clear: a crash between the two leaves the id pointing at
403 // the now-canceled sub, which the canceled branch above converges on retry.
404 $subscriptionModel->mergeConfig(['stripe_replaced_vendor_sub_id' => $existingVendorSubId]);
405 $subscriptionModel->update(['vendor_subscription_id' => '']);
406
407 return null;
408 }
409
410 /**
411 * The intent (payment or setup) must still be browser-confirmable AND the
412 * subscription must bill exactly what this attempt would create — a cart
413 * edited between attempts mints new Stripe price ids, and reusing the old
414 * subscription would charge the wrong amount.
415 */
416 private function remoteSubscriptionIsConfirmable($remoteSub, $requestData)
417 {
418 if (!$requestData) {
419 return false;
420 }
421
422 $confirmable = ['requires_payment_method', 'requires_confirmation', 'requires_action'];
423
424 $intentStatus = Arr::get($remoteSub, 'latest_invoice.payment_intent.status');
425 $clientSecret = Arr::get($remoteSub, 'latest_invoice.confirmation_secret.client_secret');
426 if (!$intentStatus) {
427 $intentStatus = Arr::get($remoteSub, 'pending_setup_intent.status');
428 $clientSecret = Arr::get($remoteSub, 'pending_setup_intent.client_secret');
429 }
430
431 if (!in_array($intentStatus, $confirmable, true) || !$clientSecret) {
432 return false;
433 }
434
435 return $this->subscriptionChargeMaterialMatches($remoteSub, $requestData);
436 }
437
438 /**
439 * Recurring items are compared against `items.data`; one-off signup/addon
440 * lines live only on the first invoice, so they are compared against
441 * `latest_invoice.lines` when the invoice carries any — a $0 trial invoice
442 * often carries none, and falling back to the item comparison there keeps
443 * trial checkouts reusable instead of cancelling a live intent.
444 */
445 private function subscriptionChargeMaterialMatches($remoteSub, $requestData)
446 {
447 $wantedItems = [];
448 foreach ((array)Arr::get($requestData, 'items', []) as $item) {
449 $priceId = Arr::get($item, 'plan', Arr::get($item, 'price'));
450 $wantedItems[] = (string)$priceId . ':' . (int)(Arr::get($item, 'quantity') ?: 1);
451 }
452
453 $remoteItems = [];
454 foreach ((array)Arr::get($remoteSub, 'items.data', []) as $item) {
455 $priceId = Arr::get($item, 'price.id', Arr::get($item, 'plan.id'));
456 $remoteItems[] = (string)$priceId . ':' . (int)(Arr::get($item, 'quantity') ?: 1);
457 }
458
459 sort($wantedItems);
460 sort($remoteItems);
461
462 if (!$wantedItems || $wantedItems !== $remoteItems) {
463 return false;
464 }
465
466 $remoteLines = [];
467 foreach ((array)Arr::get($remoteSub, 'latest_invoice.lines.data', []) as $line) {
468 $remoteLines[] = (string)Arr::get($line, 'price.id', Arr::get($line, 'plan.id'));
469 }
470
471 if (!$remoteLines) {
472 return true;
473 }
474
475 $wantedLines = [];
476 foreach ((array)Arr::get($requestData, 'items', []) as $item) {
477 $wantedLines[] = (string)Arr::get($item, 'plan', Arr::get($item, 'price'));
478 }
479 foreach ((array)Arr::get($requestData, 'add_invoice_items', []) as $item) {
480 $wantedLines[] = (string)Arr::get($item, 'price');
481 }
482
483 $remoteLines = array_values(array_unique($remoteLines));
484 $wantedLines = array_values(array_unique($wantedLines));
485
486 sort($remoteLines);
487 sort($wantedLines);
488
489 return $wantedLines === $remoteLines;
490 }
491
492 /**
493 * One-time analogue of guardExistingRemoteSubscription(). A resubmit whose
494 * charge-material params changed (or whose key aged past Stripe's 24h window)
495 * would mint a second PaymentIntent while the first stays confirmable in any
496 * stale tab — and a charge on that orphan is dropped by the webhook with no
497 * local record. Succeeded remote: record the payment and stop the re-charge.
498 * In-flight (processing / requires_capture): stop and let it settle.
499 * Confirmable with matching charge-material params: reuse it. Mismatched:
500 * cancel it so exactly one confirmable intent exists. Lookup/cancel failures
501 * fail CLOSED (WP_Error, retryable) rather than falling through to create —
502 * otherwise a transient Stripe error would let a second intent get created
503 * while the first stays confirmable, reopening the orphan path this guards.
504 *
505 * Returns null (create fresh), the reusable intent array, a redirect response
506 * array (already-succeeded — checkout's GET render has no order-status check,
507 * so the caller must push the browser to the receipt page itself rather than
508 * ask the customer to refresh), or WP_Error (stop, retryable).
509 */
510 private function guardExistingPaymentIntent(PaymentInstance $paymentInstance, $intentData)
511 {
512 $transaction = $paymentInstance->transaction;
513 $existingIntentId = $transaction->vendor_charge_id;
514
515 if (!$existingIntentId || strpos($existingIntentId, 'pi_') !== 0) {
516 return null;
517 }
518
519 $existingIntent = (new API())->getStripeObject('payment_intents/' . $existingIntentId, [
520 'expand' => ['latest_charge']
521 ], 'current');
522
523 if (is_wp_error($existingIntent)) {
524 fluent_cart_warning_log(
525 'Stripe existing payment intent lookup failed',
526 $existingIntent->get_error_message() . ' (' . $existingIntentId . ')',
527 [
528 'module_name' => 'order',
529 'module_id' => $transaction->order_id,
530 'log_type' => 'api'
531 ]
532 );
533 return new \WP_Error(
534 'stripe_payment_intent_lookup_failed',
535 __('We could not verify your previous payment attempt. Please wait a moment and try again.', 'fluent-cart')
536 );
537 }
538
539 $intentStatus = Arr::get($existingIntent, 'status');
540
541 if ('succeeded' === $intentStatus) {
542 $charge = Arr::get($existingIntent, 'latest_charge', []);
543 (new Confirmations())->confirmPaymentSuccessByCharge($transaction, [
544 'charge' => is_array($charge) ? $charge : [],
545 'intent_id' => $existingIntentId
546 ]);
547
548 // Local state is already synced to success — send the browser straight
549 // to the receipt instead of erroring and telling the customer to refresh
550 // a page that has no idea their order is paid.
551 return [
552 'fct_redirect' => true,
553 'status' => 'success',
554 'redirect_to' => $transaction->getSuccessUrl(),
555 'message' => __('Your payment has already been processed. Redirecting to your order...', 'fluent-cart')
556 ];
557 }
558
559 if (in_array($intentStatus, ['processing', 'requires_capture'], true)) {
560 return new \WP_Error(
561 'stripe_payment_in_flight',
562 __('Your previous payment attempt is still being processed. Please wait a moment before trying again — do not resubmit.', 'fluent-cart')
563 );
564 }
565
566 if (in_array($intentStatus, ['requires_payment_method', 'requires_confirmation', 'requires_action'], true)) {
567 $chargeMaterialMatches = (int)Arr::get($existingIntent, 'amount') === (int)Arr::get($intentData, 'amount')
568 && strtolower((string)Arr::get($existingIntent, 'currency')) === strtolower((string)Arr::get($intentData, 'currency'))
569 && Arr::get($existingIntent, 'customer') === Arr::get($intentData, 'customer');
570
571 if ($chargeMaterialMatches) {
572 return $existingIntent;
573 }
574
575 $cancelResponse = (new API())->createStripeObject('payment_intents/' . $existingIntentId . '/cancel', [], 'current');
576 if (is_wp_error($cancelResponse)) {
577 fluent_cart_warning_log(
578 'Stripe stale payment intent cancel failed',
579 $cancelResponse->get_error_message() . ' (' . $existingIntentId . ')',
580 [
581 'module_name' => 'order',
582 'module_id' => $transaction->order_id,
583 'log_type' => 'api'
584 ]
585 );
586 return new \WP_Error(
587 'stripe_payment_intent_cancel_failed',
588 __('We could not update your previous payment attempt. Please wait a moment and try again.', 'fluent-cart')
589 );
590 }
591 }
592
593 return null;
594 }
595
596
597 /**
598 * Handle single payment for stripe (onsite or hosted)
599 *
600 * @return \WP_Error|array
601 */
602 /**
603 * Zero-payable system (auto-charged) subscription checkout — a free trial with
604 * nothing to pay today. A $0 PaymentIntent is invalid, so the card is vaulted
605 * via a SetupIntent instead; confirmation (Confirmations::confirmSetupIntent)
606 * persists the token, completes the $0 order, and activates the trial. The
607 * trial-end invoice is then charged off-session like any other system renewal.
608 *
609 * Consent is REQUIRED here (not just disclosed): without a saved card the
610 * trial can never bill, so a checkout without the consent flag is rejected.
611 */
612 public function handleSetupOnlyPayment(PaymentInstance $paymentInstance, $paymentArgs = [])
613 {
614 $order = $paymentInstance->order;
615 $transaction = $paymentInstance->transaction;
616 $fcCustomer = $order->customer;
617 $billingAddress = $order->billing_address;
618
619 $consent = sanitize_text_field(App::request()->get('_fct_system_consent', ''));
620 if ($consent !== 'yes') {
621 return new \WP_Error(
622 'consent_required',
623 __('Please agree to save your payment method for automatic renewal charges to start this subscription.', 'fluent-cart')
624 );
625 }
626
627 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($fcCustomer);
628 if (is_wp_error($stripeCustomer)) {
629 return $stripeCustomer;
630 }
631
632 $intentData = [
633 'customer' => $stripeCustomer['id'],
634 'usage' => 'off_session',
635 'automatic_payment_methods' => ['enabled' => 'true'],
636 'metadata' => apply_filters('fluent_cart/payments/stripe_metadata_onetime', [
637 'fct_ref_id' => $order->uuid,
638 'Name' => $fcCustomer->full_name,
639 'Email' => $fcCustomer->email,
640 'order_reference' => 'fct_order_id_' . $order->id,
641 ], [
642 'order' => $order,
643 'transaction' => $transaction
644 ]),
645 ];
646
647 $intent = (new API())->createStripeObject('setup_intents', $intentData);
648
649 if (is_wp_error($intent)) {
650 return $intent;
651 }
652
653 // confirmSetupIntent() resolves the transaction by this id (and clears it
654 // after confirmation — a setup intent id is not a charge id).
655 $transaction->update([
656 'vendor_charge_id' => $intent['id']
657 ]);
658
659 $paymentArgs['public_key'] = (new StripeSettingsBase())->getPublicKey();
660 // The AJAX confirm endpoint requires the transaction hash for seti_ ids.
661 $paymentArgs['trx_hash'] = $transaction->uuid;
662
663 $customerData = [
664 'name' => $fcCustomer->first_name . ' ' . $fcCustomer->last_name,
665 'email' => $fcCustomer->email,
666 'address_1' => $billingAddress ? $billingAddress->address_1 : '',
667 'address_2' => $billingAddress ? $billingAddress->address_2 : '',
668 'city' => $billingAddress ? $billingAddress->city : '',
669 'state' => $billingAddress ? $billingAddress->state : '',
670 'postcode' => $billingAddress ? $billingAddress->postcode : '',
671 'country' => $billingAddress ? $billingAddress->country : ''
672 ];
673
674 return [
675 'status' => 'success',
676 'nextAction' => 'stripe',
677 'actionName' => 'custom',
678 'message' => __('Order has been placed successfully', 'fluent-cart'),
679 'response' => $intent,
680 'payment_args' => $paymentArgs,
681 'fc_customer' => $customerData
682 ];
683 }
684
685 /**
686 * Hosted-checkout counterpart to handleSetupOnlyPayment() — hosted mode never
687 * loads Stripe.js/Elements, so a zero-payable system-subscription checkout
688 * redirects to a Checkout Session in `mode: setup` instead of a client-side
689 * SetupIntent. The session's auto-created setup_intent id is stored as
690 * vendor_charge_id so setup_intent.succeeded / confirmByCheckoutSession
691 * resolve the transaction exactly like the onsite path.
692 */
693 public function handleHostedSetupOnlyCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
694 {
695 $order = $paymentInstance->order;
696 $transaction = $paymentInstance->transaction;
697 $fcCustomer = $order->customer;
698
699 $consent = sanitize_text_field(App::request()->get('_fct_system_consent', ''));
700 if ($consent !== 'yes') {
701 return new \WP_Error(
702 'consent_required',
703 __('Please agree to save your payment method for automatic renewal charges to start this subscription.', 'fluent-cart')
704 );
705 }
706
707 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($fcCustomer);
708 if (is_wp_error($stripeCustomer)) {
709 return $stripeCustomer;
710 }
711
712 $transactionCurrency = $transaction->currency;
713
714 $sessionData = [
715 'customer' => $stripeCustomer['id'],
716 'client_reference_id' => $order->uuid,
717 'mode' => 'setup',
718 'currency' => strtolower($transactionCurrency),
719 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
720 'cancel_url' => StripeHelper::getCancelUrl(),
721 'metadata' => [
722 'fct_ref_id' => $order->uuid,
723 'transaction_hash' => $transaction->uuid,
724 'order_reference' => 'fct_order_id_' . $order->id,
725 ],
726 ];
727
728 $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $sessionData, [
729 'order' => $order,
730 'transaction' => $transaction
731 ]);
732
733 // Same duplicate-charge defense as every other Stripe create path.
734 $idempotencyFingerprint = [
735 'customer' => Arr::get($sessionData, 'customer'),
736 'mode' => Arr::get($sessionData, 'mode'),
737 'currency' => Arr::get($sessionData, 'currency'),
738 ];
739 $idempotencySeed = $paymentInstance->getIdempotencySeed();
740 $idempotencyKey = $idempotencySeed
741 ? 'fct_stripe_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
742 : null;
743
744 $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
745 'Idempotency-Key' => $idempotencyKey
746 ]);
747
748 if (is_wp_error($session)) {
749 return $session;
750 }
751
752 // confirmSetupIntent() resolves the transaction by this id (and clears it
753 // after confirmation — a setup intent id is not a charge id).
754 $transaction->update([
755 'vendor_charge_id' => Arr::get($session, 'setup_intent'),
756 'meta' => array_merge($transaction->meta ?? [], [
757 'session_id' => $session['id']
758 ])
759 ]);
760
761 return [
762 'status' => 'success',
763 'nextAction' => 'stripe',
764 'actionName' => 'redirect',
765 'message' => __('Redirecting to Stripe checkout...', 'fluent-cart'),
766 'response' => $session,
767 'payment_args' => array_merge($paymentArgs, [
768 'checkout_url' => $session['url'],
769 'session_id' => $session['id']
770 ])
771 ];
772 }
773
774 public function handleSinglePayment(PaymentInstance $paymentInstance, $paymentArgs = [])
775 {
776 $stripeSettings = new StripeSettingsBase();
777 $checkoutMode = $stripeSettings->get('checkout_mode') ?? 'onsite';
778
779 if ($checkoutMode === 'hosted') {
780 return $this->handleHostedCheckout($paymentInstance, $paymentArgs);
781 }
782
783 // Original onsite payment flow
784 $order = $paymentInstance->order;
785 $transaction = $paymentInstance->transaction;
786 $fcCustomer = $paymentInstance->order->customer;
787 $billingAddress = $order->billing_address;
788
789 $transactionCurrency = $transaction->currency;
790 $intentAmount = (int)$transaction->total;
791
792 if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
793 $intentAmount = (int)($intentAmount / 100);
794 }
795
796 $intentData = [
797 'amount' => $intentAmount,
798 'currency' => $transactionCurrency,
799 'automatic_payment_methods' => ['enabled' => 'true'],
800 'metadata' => apply_filters('fluent_cart/payments/stripe_metadata_onetime', [
801 'fct_ref_id' => $order->uuid,
802 'Name' => $order->customer->full_name,
803 'Email' => $order->customer->email,
804 'order_reference' => 'fct_order_id_' . $paymentInstance->order->id,
805 ], [
806 'order' => $order,
807 'transaction' => $transaction
808 ]),
809 ];
810
811 $itemCount = 1;
812 foreach($paymentInstance->order->order_items as $item) {
813 $intentData['metadata']['item ' . $itemCount] = 'Name: ' . $item->title . ', ' . 'Qty: ' . $item->quantity . ', Price: ' . Helper::toDecimal($item->line_total, false, null, true, true, false);
814 if (count($intentData['metadata']) > 49) {
815 break;
816 }
817 $itemCount++;
818 }
819
820 if (!empty($paymentArgs['customer'])) {
821 $intentData['customer'] = $paymentArgs['customer'];
822 } else {
823 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($order->customer);
824 if (is_wp_error($stripeCustomer)) {
825 return $stripeCustomer;
826 }
827 $intentData['customer'] = $stripeCustomer['id'];
828 }
829
830 if (!empty($paymentArgs['setup_future_usage'])) {
831 $intentData['setup_future_usage'] = $paymentArgs['setup_future_usage'];
832 }
833
834 $paymentArgs['public_key'] = (new StripeSettingsBase())->getPublicKey();
835
836 $intentData = apply_filters('fluent_cart/payments/stripe_onetime_intent_args', $intentData, [
837 'order' => $order,
838 'transaction' => $transaction
839 ]);
840
841 // Reuse or retire any intent this transaction already holds — the idempotency
842 // key alone cannot cover a resubmit whose charge-material params changed or
843 // whose key aged out of Stripe's 24h window.
844 $intent = $this->guardExistingPaymentIntent($paymentInstance, $intentData);
845 if (is_wp_error($intent)) {
846 return $intent;
847 }
848
849 if (!empty($intent['fct_redirect'])) {
850 return $intent;
851 }
852
853 if (!$intent) {
854 // Same duplicate-charge defense for one-time onsite payments. Customer is in
855 // the fingerprint because a guest editing their email between attempts maps to
856 // a different Stripe customer — same key there would 400 for the key's 24h
857 // lifetime. Built AFTER the intent-args filter so filtered amounts are what
858 // get fingerprinted.
859 $idempotencyFingerprint = [
860 'amount' => Arr::get($intentData, 'amount'),
861 'currency' => Arr::get($intentData, 'currency'),
862 'customer' => Arr::get($intentData, 'customer'),
863 ];
864 $idempotencySeed = $paymentInstance->getIdempotencySeed();
865 $idempotencyKey = $idempotencySeed
866 ? 'fct_stripe_pi_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
867 : null;
868
869 $intent = (new API())->createStripeObject('payment_intents', $intentData, 'current', [
870 'Idempotency-Key' => $idempotencyKey
871 ]);
872
873 if (is_wp_error($intent)) {
874 return $intent;
875 }
876
877 $transaction->update([
878 'vendor_charge_id' => $intent['id']
879 ]);
880 }
881
882 $customerData = [
883 'name' => $fcCustomer->first_name . ' ' . $fcCustomer->last_name,
884 'email' => $fcCustomer->email,
885 'address_1' => $billingAddress->address_1,
886 'address_2' => $billingAddress->address_2,
887 'city' => $billingAddress->city,
888 'state' => $billingAddress->state,
889 'postcode' => $billingAddress->postcode,
890 'country' => $billingAddress->country
891 ];
892
893 return [
894 'status' => 'success',
895 'nextAction' => 'stripe',
896 'actionName' => 'custom',
897 'message' => __('Order has been placed successfully', 'fluent-cart'),
898 'response' => $intent,
899 'payment_args' => $paymentArgs,
900 'fc_customer' => $customerData
901 ];
902 }
903
904
905 private function handleHostedCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
906 {
907 $order = $paymentInstance->order;
908 $transaction = $paymentInstance->transaction;
909 $fcCustomer = $order->customer;
910 $billingAddress = $order->billing_address;
911
912 $transactionCurrency = $transaction->currency;
913 $chargeAmount = (int)$transaction->total;
914
915 if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
916 $chargeAmount = (int)($chargeAmount / 100);
917 }
918
919 // Create or get Stripe customer
920 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($fcCustomer);
921 if (is_wp_error($stripeCustomer)) {
922 return $stripeCustomer;
923 }
924
925 // Use a single line item with the total amount to avoid complexity
926 // This is simpler and prevents any calculation mismatches
927 $storeName = (new \FluentCart\Api\StoreSettings())->get('store_name');
928 $lineItems = [
929 [
930 'price_data' => [
931 'currency' => strtolower($transactionCurrency),
932 'product_data' => [
933 'name' => $storeName . ' - Order #' . $order->uuid,
934 'description' => sprintf(__('Order total including all items, shipping (If any), and taxes (If any)', 'fluent-cart')),
935 ],
936 'unit_amount' => $chargeAmount,
937 ],
938 'quantity' => 1,
939 ]
940 ];
941
942 $sessionData = [
943 'customer' => $stripeCustomer['id'],
944 'client_reference_id' => $order->uuid,
945 'line_items' => $lineItems,
946 'mode' => 'payment',
947 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
948 'cancel_url' => StripeHelper::getCancelUrl(),
949 'metadata' => [
950 'fct_ref_id' => $order->uuid,
951 'transaction_hash' => $transaction->uuid,
952 'order_reference' => 'fct_order_id_' . $order->id,
953 ],
954 ];
955
956 // Same vaulting contract as the onsite intent path (see setup_future_usage
957 // above) — a mode: payment Checkout Session only saves the card when this
958 // is set on payment_intent_data.
959 if (!empty($paymentArgs['setup_future_usage'])) {
960 $sessionData['payment_intent_data'] = [
961 'setup_future_usage' => $paymentArgs['setup_future_usage'],
962 ];
963 }
964
965 $itemCount = 1;
966 foreach($order->order_items as $item) {
967 $sessionData['metadata']['item ' . $itemCount] = 'Name: ' . $item->title . ', ' . 'Qty: ' . $item->quantity . ', Price: ' . Helper::toDecimal($item->line_total, false, null, true, true, false);
968 if (count($sessionData['metadata']) > 49) {
969 break;
970 }
971
972 $itemCount++;
973 }
974
975 $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $sessionData, [
976 'order' => $order,
977 'transaction' => $transaction
978 ]);
979
980 // Same duplicate-charge defense as every other Stripe create path: a pure
981 // duplicate replays the key and gets the original session back; an edited-cart
982 // resubmit gets a fresh key instead of a same-key/changed-parameters 400.
983 $idempotencyFingerprint = [
984 'customer' => Arr::get($sessionData, 'customer'),
985 'line_items' => Arr::get($sessionData, 'line_items'),
986 'mode' => Arr::get($sessionData, 'mode'),
987 ];
988 $idempotencySeed = $paymentInstance->getIdempotencySeed();
989 $idempotencyKey = $idempotencySeed
990 ? 'fct_stripe_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
991 : null;
992
993 $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
994 'Idempotency-Key' => $idempotencyKey
995 ]);
996
997 if (is_wp_error($session)) {
998 return $session;
999 }
1000
1001 $transaction->update([
1002 'meta' => array_merge($transaction->meta ?? [], [
1003 'session_id' => $session['id']
1004 ])
1005 ]);
1006
1007 return [
1008 'status' => 'success',
1009 'nextAction' => 'stripe',
1010 'actionName' => 'redirect',
1011 'message' => __('Redirecting to Stripe checkout...', 'fluent-cart'),
1012 'response' => $session,
1013 'payment_args' => array_merge($paymentArgs, [
1014 'checkout_url' => $session['url'],
1015 'session_id' => $session['id']
1016 ])
1017 ];
1018 }
1019
1020
1021 private function handleHostedSubscriptionCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
1022 {
1023 $order = $paymentInstance->order;
1024 $transaction = $paymentInstance->transaction;
1025 $subscriptionModel = $paymentInstance->subscription;
1026 $fcCustomer = $order->customer;
1027
1028 if (!$subscriptionModel) {
1029 return new \WP_Error('no_subscription', __('No subscription found.', 'fluent-cart'));
1030 }
1031
1032 // No request body: a hosted Checkout Session mints its own subscription,
1033 // so nothing is reusable here.
1034 $guardError = $this->guardExistingRemoteSubscription($subscriptionModel, $order);
1035 if (is_wp_error($guardError)) {
1036 return $guardError;
1037 }
1038
1039 $transactionCurrency = $transaction->currency;
1040 $orderType = $order->type;
1041
1042 // Create or get Stripe customer
1043 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($fcCustomer);
1044 if (is_wp_error($stripeCustomer)) {
1045 return $stripeCustomer;
1046 }
1047
1048 // Get or create Stripe price/plan
1049 if ($orderType == 'renewal') {
1050 $stripePlan = Plan::getStripePricing([
1051 'product_id' => $subscriptionModel->product_id,
1052 'variation_id' => $subscriptionModel->variation_id,
1053 'billing_interval' => $subscriptionModel->billing_interval,
1054 'recurring_total' => $subscriptionModel->getCurrentRenewalAmount(),
1055 'currency' => $order->currency,
1056 'trial_days' => $subscriptionModel->getReactivationTrialDays(),
1057 'interval_count' => 1,
1058 'order_id' => $subscriptionModel->parent_order_id,
1059 ]);
1060 } else {
1061 $stripePlan = Plan::getStripePricing([
1062 'product_id' => $subscriptionModel->product_id,
1063 'variation_id' => $subscriptionModel->variation_id,
1064 'billing_interval' => $subscriptionModel->billing_interval,
1065 'recurring_total' => $subscriptionModel->recurring_total,
1066 'currency' => $order->currency,
1067 'trial_days' => (int)$subscriptionModel->trial_days,
1068 'interval_count' => 1,
1069 'order_id' => $subscriptionModel->parent_order_id,
1070 ]);
1071 }
1072
1073 if (is_wp_error($stripePlan)) {
1074 return $stripePlan;
1075 }
1076
1077
1078 $feeTotal = $orderType !== 'renewal' ? (int)$paymentInstance->order->fee_total : 0;
1079 $initialAmount = (int)$subscriptionModel->signup_fee + $paymentInstance->getExtraAddonAmount() + $feeTotal;
1080
1081 if ($orderType == 'renewal') {
1082 $initialAmount = 0;
1083 }
1084
1085 $recurringTotal = (int)$subscriptionModel->recurring_total;
1086 if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
1087 $initialAmount = (int)($initialAmount / 100);
1088 $recurringTotal = (int)($recurringTotal / 100);
1089 }
1090
1091 $lineItems = [
1092 [
1093 'price' => $stripePlan['id'],
1094 'quantity' => $subscriptionModel->quantity ?: 1,
1095 ]
1096 ];
1097
1098 $subscriptionData = [
1099 'metadata' => [
1100 'fct_ref_id' => $order->uuid,
1101 'email' => $fcCustomer->email,
1102 'name' => $order->full_name,
1103 'order_reference' => 'fct_order_id_' . $order->id,
1104 'subscription_item' => $subscriptionModel->item_name,
1105 ],
1106 ];
1107
1108 // Handle trial period if set in plan (same as onsite lines 94-96)
1109 if (!empty($stripePlan['trial_period_days'])) {
1110 $subscriptionData['trial_period_days'] = $stripePlan['trial_period_days'];
1111 }
1112
1113 if ($initialAmount > 0) {
1114 $addonPrice = Plan::getOneTimeAddonPrice([
1115 'product_id' => $subscriptionModel->product_id,
1116 'currency' => $order->currency,
1117 'amount' => (int)$initialAmount,
1118 'name' => __('Signup fee / initial payment', 'fluent-cart'),
1119 'variation_id' => $subscriptionModel->variation_id,
1120 'order_id' => $subscriptionModel->parent_order_id,
1121
1122 ]);
1123
1124 if (is_wp_error($addonPrice)) {
1125 return $addonPrice;
1126 };
1127
1128 $lineItems[] = [
1129 'price' => $addonPrice['id'],
1130 'quantity' => 1
1131 ];
1132 }
1133
1134 $sessionData = [
1135 'customer' => $stripeCustomer['id'],
1136 'client_reference_id' => $order->uuid,
1137 'line_items' => $lineItems,
1138 'mode' => 'subscription',
1139 'consent_collection' => ['payment_method_reuse_agreement' => ['position' => 'hidden']],
1140 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
1141 'cancel_url' => StripeHelper::getCancelUrl(),
1142 'subscription_data' => $subscriptionData,
1143 'metadata' => [
1144 'fct_ref_id' => $order->uuid,
1145 'subscription_item' => $subscriptionModel->item_name,
1146 'transaction_hash' => $transaction->uuid,
1147 'order_reference' => 'fct_order_id_' . $order->id,
1148 ],
1149 ];
1150
1151 $sessionData = apply_filters('fluent_cart/payments/stripe_subscription_checkout_session_args', $sessionData, [
1152 'order' => $order,
1153 'transaction' => $transaction,
1154 'subscription' => $subscriptionModel
1155 ]);
1156
1157 // Same duplicate-subscription defense as the onsite path, applied to the hosted
1158 // Checkout Session. Metadata is excluded so a volatile metadata filter cannot
1159 // change the key on a genuine duplicate and reopen the double-charge window.
1160 $idempotencyFingerprint = [
1161 'customer' => Arr::get($sessionData, 'customer'),
1162 'line_items' => Arr::get($sessionData, 'line_items'),
1163 'mode' => Arr::get($sessionData, 'mode'),
1164 'subscription_data' => Arr::get($sessionData, 'subscription_data'),
1165 // See the onsite path: rolls the key after a guard cancel, read from
1166 // the persisted marker so retries recompute the same key.
1167 'replaces' => (string)Arr::get(
1168 (array)$subscriptionModel->config,
1169 'stripe_replaced_vendor_sub_id',
1170 ''
1171 ),
1172 ];
1173 $idempotencySeed = $paymentInstance->getIdempotencySeed();
1174 $idempotencyKey = $idempotencySeed
1175 ? 'fct_stripe_sub_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
1176 : null;
1177
1178 $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
1179 'Idempotency-Key' => $idempotencyKey
1180 ]);
1181
1182 if (is_wp_error($session)) {
1183 return $session;
1184 }
1185
1186 $subscriptionModel->update([
1187 'vendor_customer_id' => $stripeCustomer['id']
1188 ]);
1189
1190 $transaction->update([
1191 'vendor_charge_id' => Arr::get($session, 'payment_intent', Arr::get($session, 'id')),
1192 'meta' => array_merge($transaction->meta ?? [], [
1193 'session_id' => $session['id']
1194 ])
1195 ]);
1196
1197 return [
1198 'status' => 'success',
1199 'nextAction' => 'stripe',
1200 'actionName' => 'redirect',
1201 'message' => __('Redirecting to Stripe checkout...', 'fluent-cart'),
1202 'response' => $session,
1203 'payment_args' => array_merge($paymentArgs, [
1204 'checkout_url' => $session['url'],
1205 'session_id' => $session['id']
1206 ])
1207 ];
1208 }
1209
1210 }
1211