PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.1
1.6.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 / Processor.php

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

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