PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.0
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.0, at app/Modules/PaymentMethods/StripeGateway/Processor.php

870 lines 36.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\StripeGateway;
4
5 use FluentCart\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 if ($orderType == 'renewal' && Arr::get($stripePlan, 'trial_period_days', 0) > 0) {
197 $config = $subscriptionModel->config ?: [];
198 $subscriptionUpdateFields['config'] = array_merge($config, ['is_trial_days_simulated' => 'yes']);
199 }
200
201 $subscriptionModel->update($subscriptionUpdateFields);
202
203 if ($stripeSubscription['pending_setup_intent'] != null) {
204 $paymentArgs['vendor_subscription_info'] = [
205 'type' => 'setup',
206 'clientSecret' => Arr::get($stripeSubscription, 'pending_setup_intent.client_secret'),
207 'trx_hash' => $paymentInstance->transaction->uuid,
208 ];
209 } else {
210 $paymentArgs['vendor_subscription_info'] = [
211 'type' => 'payment',
212 'clientSecret' => Arr::get($stripeSubscription, 'latest_invoice.confirmation_secret.client_secret')
213 ];
214 }
215
216 $customerData = [
217 'name' => $fcCustomer->first_name . ' ' . $fcCustomer->last_name,
218 'email' => $fcCustomer->email,
219 'address_1' => $billingAddress->address_1,
220 'address_2' => $billingAddress->address_2,
221 'city' => $billingAddress->city,
222 'state' => $billingAddress->state,
223 'postcode' => $billingAddress->postcode,
224 'country' => $billingAddress->country
225 ];
226
227 return [
228 'nextAction' => 'stripe',
229 'actionName' => 'custom',
230 'status' => 'success',
231 'message' => __('Order has been placed successfully', 'fluent-cart'),
232 'payment_args' => $paymentArgs,
233 'response' => $stripeSubscription,
234 'fc_customer' => $customerData
235 ];
236 }
237
238 /**
239 * A retryable checkout can arrive with a live Stripe subscription already
240 * attached — the previous create succeeded but its confirm/webhook never
241 * landed, and a changed cart mints a fresh idempotency key, so the key alone
242 * cannot stop a second create. A second create bills the customer on a
243 * subscription the store cannot see or cancel. Billing-active remote: block
244 * the create and re-sync local state from Stripe. Unconfirmed incomplete
245 * remote: cancel it so the fresh create is the only confirmable one.
246 */
247 private function guardExistingRemoteSubscription($subscriptionModel)
248 {
249 $existingVendorSubId = $subscriptionModel->vendor_subscription_id;
250 if (!$existingVendorSubId) {
251 return null;
252 }
253
254 $remoteSub = (new API())->getStripeObject('subscriptions/' . $existingVendorSubId, [], 'current');
255 if (is_wp_error($remoteSub)) {
256 return null;
257 }
258
259 $remoteStatus = Arr::get($remoteSub, 'status');
260
261 if (in_array($remoteStatus, ['active', 'trialing', 'past_due', 'unpaid'], true)) {
262 (new StripeSubscriptions())->reSyncSubscriptionFromRemote($subscriptionModel);
263 return new \WP_Error(
264 'stripe_subscription_already_active',
265 __('Your subscription payment has already been processed. Please refresh this page to see your order status instead of paying again.', 'fluent-cart')
266 );
267 }
268
269 if ('incomplete' === $remoteStatus) {
270 $cancelResponse = (new API())->deleteStripeObject('subscriptions/' . $existingVendorSubId, [], 'current');
271 if (is_wp_error($cancelResponse)) {
272 fluent_cart_error_log('Stripe stale incomplete subscription cancel failed. Subscription ID: ' . $subscriptionModel->id, $cancelResponse->get_error_message());
273 }
274 }
275
276 return null;
277 }
278
279
280 /**
281 * Handle single payment for stripe (onsite or hosted)
282 *
283 * @return \WP_Error|array
284 */
285 /**
286 * Zero-payable system (auto-charged) subscription checkout — a free trial with
287 * nothing to pay today. A $0 PaymentIntent is invalid, so the card is vaulted
288 * via a SetupIntent instead; confirmation (Confirmations::confirmSetupIntent)
289 * persists the token, completes the $0 order, and activates the trial. The
290 * trial-end invoice is then charged off-session like any other system renewal.
291 *
292 * Consent is REQUIRED here (not just disclosed): without a saved card the
293 * trial can never bill, so a checkout without the consent flag is rejected.
294 */
295 public function handleSetupOnlyPayment(PaymentInstance $paymentInstance, $paymentArgs = [])
296 {
297 $order = $paymentInstance->order;
298 $transaction = $paymentInstance->transaction;
299 $fcCustomer = $order->customer;
300 $billingAddress = $order->billing_address;
301
302 $consent = sanitize_text_field(App::request()->get('_fct_system_consent', ''));
303 if ($consent !== 'yes') {
304 return new \WP_Error(
305 'consent_required',
306 __('Please agree to save your payment method for automatic renewal charges to start this subscription.', 'fluent-cart')
307 );
308 }
309
310 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($fcCustomer);
311 if (is_wp_error($stripeCustomer)) {
312 return $stripeCustomer;
313 }
314
315 $intentData = [
316 'customer' => $stripeCustomer['id'],
317 'usage' => 'off_session',
318 'automatic_payment_methods' => ['enabled' => 'true'],
319 'metadata' => apply_filters('fluent_cart/payments/stripe_metadata_onetime', [
320 'fct_ref_id' => $order->uuid,
321 'Name' => $fcCustomer->full_name,
322 'Email' => $fcCustomer->email,
323 'order_reference' => 'fct_order_id_' . $order->id,
324 ], [
325 'order' => $order,
326 'transaction' => $transaction
327 ]),
328 ];
329
330 $intent = (new API())->createStripeObject('setup_intents', $intentData);
331
332 if (is_wp_error($intent)) {
333 return $intent;
334 }
335
336 // confirmSetupIntent() resolves the transaction by this id (and clears it
337 // after confirmation — a setup intent id is not a charge id).
338 $transaction->update([
339 'vendor_charge_id' => $intent['id']
340 ]);
341
342 $paymentArgs['public_key'] = (new StripeSettingsBase())->getPublicKey();
343 // The AJAX confirm endpoint requires the transaction hash for seti_ ids.
344 $paymentArgs['trx_hash'] = $transaction->uuid;
345
346 $customerData = [
347 'name' => $fcCustomer->first_name . ' ' . $fcCustomer->last_name,
348 'email' => $fcCustomer->email,
349 'address_1' => $billingAddress ? $billingAddress->address_1 : '',
350 'address_2' => $billingAddress ? $billingAddress->address_2 : '',
351 'city' => $billingAddress ? $billingAddress->city : '',
352 'state' => $billingAddress ? $billingAddress->state : '',
353 'postcode' => $billingAddress ? $billingAddress->postcode : '',
354 'country' => $billingAddress ? $billingAddress->country : ''
355 ];
356
357 return [
358 'status' => 'success',
359 'nextAction' => 'stripe',
360 'actionName' => 'custom',
361 'message' => __('Order has been placed successfully', 'fluent-cart'),
362 'response' => $intent,
363 'payment_args' => $paymentArgs,
364 'fc_customer' => $customerData
365 ];
366 }
367
368 /**
369 * Hosted-checkout counterpart to handleSetupOnlyPayment() — hosted mode never
370 * loads Stripe.js/Elements, so a zero-payable system-subscription checkout
371 * redirects to a Checkout Session in `mode: setup` instead of a client-side
372 * SetupIntent. The session's auto-created setup_intent id is stored as
373 * vendor_charge_id so setup_intent.succeeded / confirmByCheckoutSession
374 * resolve the transaction exactly like the onsite path.
375 */
376 public function handleHostedSetupOnlyCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
377 {
378 $order = $paymentInstance->order;
379 $transaction = $paymentInstance->transaction;
380 $fcCustomer = $order->customer;
381
382 $consent = sanitize_text_field(App::request()->get('_fct_system_consent', ''));
383 if ($consent !== 'yes') {
384 return new \WP_Error(
385 'consent_required',
386 __('Please agree to save your payment method for automatic renewal charges to start this subscription.', 'fluent-cart')
387 );
388 }
389
390 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($fcCustomer);
391 if (is_wp_error($stripeCustomer)) {
392 return $stripeCustomer;
393 }
394
395 $transactionCurrency = $transaction->currency;
396
397 $sessionData = [
398 'customer' => $stripeCustomer['id'],
399 'client_reference_id' => $order->uuid,
400 'mode' => 'setup',
401 'currency' => strtolower($transactionCurrency),
402 'success_url' => Arr::get($paymentArgs, 'success_url') . '&fct_stripe_hosted=1&trx_hash=' . $transaction->uuid,
403 'cancel_url' => StripeHelper::getCancelUrl(),
404 'metadata' => [
405 'fct_ref_id' => $order->uuid,
406 'transaction_hash' => $transaction->uuid,
407 'order_reference' => 'fct_order_id_' . $order->id,
408 ],
409 ];
410
411 $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $sessionData, [
412 'order' => $order,
413 'transaction' => $transaction
414 ]);
415
416 // Same duplicate-charge defense as every other Stripe create path.
417 $idempotencyFingerprint = [
418 'customer' => Arr::get($sessionData, 'customer'),
419 'mode' => Arr::get($sessionData, 'mode'),
420 'currency' => Arr::get($sessionData, 'currency'),
421 ];
422 $idempotencySeed = $paymentInstance->getIdempotencySeed();
423 $idempotencyKey = $idempotencySeed
424 ? 'fct_stripe_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
425 : null;
426
427 $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
428 'Idempotency-Key' => $idempotencyKey
429 ]);
430
431 if (is_wp_error($session)) {
432 return $session;
433 }
434
435 // confirmSetupIntent() resolves the transaction by this id (and clears it
436 // after confirmation — a setup intent id is not a charge id).
437 $transaction->update([
438 'vendor_charge_id' => Arr::get($session, 'setup_intent'),
439 'meta' => array_merge($transaction->meta ?? [], [
440 'session_id' => $session['id']
441 ])
442 ]);
443
444 return [
445 'status' => 'success',
446 'nextAction' => 'stripe',
447 'actionName' => 'redirect',
448 'message' => __('Redirecting to Stripe checkout...', 'fluent-cart'),
449 'response' => $session,
450 'payment_args' => array_merge($paymentArgs, [
451 'checkout_url' => $session['url'],
452 'session_id' => $session['id']
453 ])
454 ];
455 }
456
457 public function handleSinglePayment(PaymentInstance $paymentInstance, $paymentArgs = [])
458 {
459 $stripeSettings = new StripeSettingsBase();
460 $checkoutMode = $stripeSettings->get('checkout_mode') ?? 'onsite';
461
462 if ($checkoutMode === 'hosted') {
463 return $this->handleHostedCheckout($paymentInstance, $paymentArgs);
464 }
465
466 // Original onsite payment flow
467 $order = $paymentInstance->order;
468 $transaction = $paymentInstance->transaction;
469 $fcCustomer = $paymentInstance->order->customer;
470 $billingAddress = $order->billing_address;
471
472 $transactionCurrency = $transaction->currency;
473 $intentAmount = (int)$transaction->total;
474
475 if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
476 $intentAmount = (int)($intentAmount / 100);
477 }
478
479 $intentData = [
480 'amount' => $intentAmount,
481 'currency' => $transactionCurrency,
482 'automatic_payment_methods' => ['enabled' => 'true'],
483 'metadata' => apply_filters('fluent_cart/payments/stripe_metadata_onetime', [
484 'fct_ref_id' => $order->uuid,
485 'Name' => $order->customer->full_name,
486 'Email' => $order->customer->email,
487 'order_reference' => 'fct_order_id_' . $paymentInstance->order->id,
488 ], [
489 'order' => $order,
490 'transaction' => $transaction
491 ]),
492 ];
493
494 $itemCount = 1;
495 foreach($paymentInstance->order->order_items as $item) {
496 $intentData['metadata']['item ' . $itemCount] = 'Name: ' . $item->title . ', ' . 'Qty: ' . $item->quantity . ', Price: ' . Helper::toDecimal($item->line_total, false, null, true, true, false);
497 if (count($intentData['metadata']) > 49) {
498 break;
499 }
500 $itemCount++;
501 }
502
503 if (!empty($paymentArgs['customer'])) {
504 $intentData['customer'] = $paymentArgs['customer'];
505 } else {
506 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($order->customer);
507 if (is_wp_error($stripeCustomer)) {
508 return $stripeCustomer;
509 }
510 $intentData['customer'] = $stripeCustomer['id'];
511 }
512
513 if (!empty($paymentArgs['setup_future_usage'])) {
514 $intentData['setup_future_usage'] = $paymentArgs['setup_future_usage'];
515 }
516
517 $paymentArgs['public_key'] = (new StripeSettingsBase())->getPublicKey();
518
519 $intentData = apply_filters('fluent_cart/payments/stripe_onetime_intent_args', $intentData, [
520 'order' => $order,
521 'transaction' => $transaction
522 ]);
523
524 // Same duplicate-charge defense for one-time onsite payments. Customer is in
525 // the fingerprint because a guest editing their email between attempts maps to
526 // a different Stripe customer — same key there would 400 for the key's 24h
527 // lifetime. Built AFTER the intent-args filter so filtered amounts are what
528 // get fingerprinted.
529 $idempotencyFingerprint = [
530 'amount' => Arr::get($intentData, 'amount'),
531 'currency' => Arr::get($intentData, 'currency'),
532 'customer' => Arr::get($intentData, 'customer'),
533 ];
534 $idempotencySeed = $paymentInstance->getIdempotencySeed();
535 $idempotencyKey = $idempotencySeed
536 ? 'fct_stripe_pi_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
537 : null;
538
539 $intent = (new API())->createStripeObject('payment_intents', $intentData, 'current', [
540 'Idempotency-Key' => $idempotencyKey
541 ]);
542
543 if (is_wp_error($intent)) {
544 return $intent;
545 }
546
547 $transaction->update([
548 'vendor_charge_id' => $intent['id']
549 ]);
550
551 $customerData = [
552 'name' => $fcCustomer->first_name . ' ' . $fcCustomer->last_name,
553 'email' => $fcCustomer->email,
554 'address_1' => $billingAddress->address_1,
555 'address_2' => $billingAddress->address_2,
556 'city' => $billingAddress->city,
557 'state' => $billingAddress->state,
558 'postcode' => $billingAddress->postcode,
559 'country' => $billingAddress->country
560 ];
561
562 return [
563 'status' => 'success',
564 'nextAction' => 'stripe',
565 'actionName' => 'custom',
566 'message' => __('Order has been placed successfully', 'fluent-cart'),
567 'response' => $intent,
568 'payment_args' => $paymentArgs,
569 'fc_customer' => $customerData
570 ];
571 }
572
573
574 private function handleHostedCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
575 {
576 $order = $paymentInstance->order;
577 $transaction = $paymentInstance->transaction;
578 $fcCustomer = $order->customer;
579 $billingAddress = $order->billing_address;
580
581 $transactionCurrency = $transaction->currency;
582 $chargeAmount = (int)$transaction->total;
583
584 if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
585 $chargeAmount = (int)($chargeAmount / 100);
586 }
587
588 // Create or get Stripe customer
589 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($fcCustomer);
590 if (is_wp_error($stripeCustomer)) {
591 return $stripeCustomer;
592 }
593
594 // Use a single line item with the total amount to avoid complexity
595 // This is simpler and prevents any calculation mismatches
596 $storeName = (new \FluentCart\Api\StoreSettings())->get('store_name');
597 $lineItems = [
598 [
599 'price_data' => [
600 'currency' => strtolower($transactionCurrency),
601 'product_data' => [
602 'name' => $storeName . ' - Order #' . $order->uuid,
603 'description' => sprintf(__('Order total including all items, shipping (If any), and taxes (If any)', 'fluent-cart')),
604 ],
605 'unit_amount' => $chargeAmount,
606 ],
607 'quantity' => 1,
608 ]
609 ];
610
611 $sessionData = [
612 'customer' => $stripeCustomer['id'],
613 'client_reference_id' => $order->uuid,
614 'line_items' => $lineItems,
615 'mode' => 'payment',
616 'success_url' => Arr::get($paymentArgs, 'success_url') . '&fct_stripe_hosted=1&trx_hash=' . $transaction->uuid,
617 'cancel_url' => StripeHelper::getCancelUrl(),
618 'metadata' => [
619 'fct_ref_id' => $order->uuid,
620 'transaction_hash' => $transaction->uuid,
621 'order_reference' => 'fct_order_id_' . $order->id,
622 ],
623 ];
624
625 // Same vaulting contract as the onsite intent path (see setup_future_usage
626 // above) — a mode: payment Checkout Session only saves the card when this
627 // is set on payment_intent_data.
628 if (!empty($paymentArgs['setup_future_usage'])) {
629 $sessionData['payment_intent_data'] = [
630 'setup_future_usage' => $paymentArgs['setup_future_usage'],
631 ];
632 }
633
634 $itemCount = 1;
635 foreach($order->order_items as $item) {
636 $sessionData['metadata']['item ' . $itemCount] = 'Name: ' . $item->title . ', ' . 'Qty: ' . $item->quantity . ', Price: ' . Helper::toDecimal($item->line_total, false, null, true, true, false);
637 if (count($sessionData['metadata']) > 49) {
638 break;
639 }
640
641 $itemCount++;
642 }
643
644 $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $sessionData, [
645 'order' => $order,
646 'transaction' => $transaction
647 ]);
648
649 // Same duplicate-charge defense as every other Stripe create path: a pure
650 // duplicate replays the key and gets the original session back; an edited-cart
651 // resubmit gets a fresh key instead of a same-key/changed-parameters 400.
652 $idempotencyFingerprint = [
653 'customer' => Arr::get($sessionData, 'customer'),
654 'line_items' => Arr::get($sessionData, 'line_items'),
655 'mode' => Arr::get($sessionData, 'mode'),
656 ];
657 $idempotencySeed = $paymentInstance->getIdempotencySeed();
658 $idempotencyKey = $idempotencySeed
659 ? 'fct_stripe_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
660 : null;
661
662 $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
663 'Idempotency-Key' => $idempotencyKey
664 ]);
665
666 if (is_wp_error($session)) {
667 return $session;
668 }
669
670 $transaction->update([
671 'meta' => array_merge($transaction->meta ?? [], [
672 'session_id' => $session['id']
673 ])
674 ]);
675
676 return [
677 'status' => 'success',
678 'nextAction' => 'stripe',
679 'actionName' => 'redirect',
680 'message' => __('Redirecting to Stripe checkout...', 'fluent-cart'),
681 'response' => $session,
682 'payment_args' => array_merge($paymentArgs, [
683 'checkout_url' => $session['url'],
684 'session_id' => $session['id']
685 ])
686 ];
687 }
688
689
690 private function handleHostedSubscriptionCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
691 {
692 $order = $paymentInstance->order;
693 $transaction = $paymentInstance->transaction;
694 $subscriptionModel = $paymentInstance->subscription;
695 $fcCustomer = $order->customer;
696
697 if (!$subscriptionModel) {
698 return new \WP_Error('no_subscription', __('No subscription found.', 'fluent-cart'));
699 }
700
701 if ($guardError = $this->guardExistingRemoteSubscription($subscriptionModel)) {
702 return $guardError;
703 }
704
705 $transactionCurrency = $transaction->currency;
706 $orderType = $order->type;
707
708 // Create or get Stripe customer
709 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($fcCustomer);
710 if (is_wp_error($stripeCustomer)) {
711 return $stripeCustomer;
712 }
713
714 // Get or create Stripe price/plan
715 if ($orderType == 'renewal') {
716 $stripePlan = Plan::getStripePricing([
717 'product_id' => $subscriptionModel->product_id,
718 'variation_id' => $subscriptionModel->variation_id,
719 'billing_interval' => $subscriptionModel->billing_interval,
720 'recurring_total' => $subscriptionModel->getCurrentRenewalAmount(),
721 'currency' => $order->currency,
722 'trial_days' => $subscriptionModel->getReactivationTrialDays(),
723 'interval_count' => 1,
724 'order_id' => $subscriptionModel->parent_order_id,
725 ]);
726 } else {
727 $stripePlan = Plan::getStripePricing([
728 'product_id' => $subscriptionModel->product_id,
729 'variation_id' => $subscriptionModel->variation_id,
730 'billing_interval' => $subscriptionModel->billing_interval,
731 'recurring_total' => $subscriptionModel->recurring_total,
732 'currency' => $order->currency,
733 'trial_days' => (int)$subscriptionModel->trial_days,
734 'interval_count' => 1,
735 'order_id' => $subscriptionModel->parent_order_id,
736 ]);
737 }
738
739 if (is_wp_error($stripePlan)) {
740 return $stripePlan;
741 }
742
743
744 $feeTotal = $orderType !== 'renewal' ? (int)$paymentInstance->order->fee_total : 0;
745 $initialAmount = (int)$subscriptionModel->signup_fee + $paymentInstance->getExtraAddonAmount() + $feeTotal;
746
747 if ($orderType == 'renewal') {
748 $initialAmount = 0;
749 }
750
751 $recurringTotal = (int)$subscriptionModel->recurring_total;
752 if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
753 $initialAmount = (int)($initialAmount / 100);
754 $recurringTotal = (int)($recurringTotal / 100);
755 }
756
757 $lineItems = [
758 [
759 'price' => $stripePlan['id'],
760 'quantity' => $subscriptionModel->quantity ?: 1,
761 ]
762 ];
763
764 $subscriptionData = [
765 'metadata' => [
766 'fct_ref_id' => $order->uuid,
767 'email' => $fcCustomer->email,
768 'name' => $order->full_name,
769 'order_reference' => 'fct_order_id_' . $order->id,
770 'subscription_item' => $subscriptionModel->item_name,
771 ],
772 ];
773
774 // Handle trial period if set in plan (same as onsite lines 94-96)
775 if (!empty($stripePlan['trial_period_days'])) {
776 $subscriptionData['trial_period_days'] = $stripePlan['trial_period_days'];
777 }
778
779 if ($initialAmount > 0) {
780 $addonPrice = Plan::getOneTimeAddonPrice([
781 'product_id' => $subscriptionModel->product_id,
782 'currency' => $order->currency,
783 'amount' => (int)$initialAmount,
784 'name' => __('Signup fee / initial payment', 'fluent-cart'),
785 'variation_id' => $subscriptionModel->variation_id,
786 'order_id' => $subscriptionModel->parent_order_id,
787
788 ]);
789
790 if (is_wp_error($addonPrice)) {
791 return $addonPrice;
792 };
793
794 $lineItems[] = [
795 'price' => $addonPrice['id'],
796 'quantity' => 1
797 ];
798 }
799
800 $sessionData = [
801 'customer' => $stripeCustomer['id'],
802 'client_reference_id' => $order->uuid,
803 'line_items' => $lineItems,
804 'mode' => 'subscription',
805 'consent_collection' => ['payment_method_reuse_agreement' => ['position' => 'hidden']],
806 'success_url' => Arr::get($paymentArgs, 'success_url') . '&fct_stripe_hosted=1&trx_hash=' . $transaction->uuid,
807 'cancel_url' => StripeHelper::getCancelUrl(),
808 'subscription_data' => $subscriptionData,
809 'metadata' => [
810 'fct_ref_id' => $order->uuid,
811 'subscription_item' => $subscriptionModel->item_name,
812 'transaction_hash' => $transaction->uuid,
813 'order_reference' => 'fct_order_id_' . $order->id,
814 ],
815 ];
816
817 $sessionData = apply_filters('fluent_cart/payments/stripe_subscription_checkout_session_args', $sessionData, [
818 'order' => $order,
819 'transaction' => $transaction,
820 'subscription' => $subscriptionModel
821 ]);
822
823 // Same duplicate-subscription defense as the onsite path, applied to the hosted
824 // Checkout Session. Metadata is excluded so a volatile metadata filter cannot
825 // change the key on a genuine duplicate and reopen the double-charge window.
826 $idempotencyFingerprint = [
827 'customer' => Arr::get($sessionData, 'customer'),
828 'line_items' => Arr::get($sessionData, 'line_items'),
829 'mode' => Arr::get($sessionData, 'mode'),
830 'subscription_data' => Arr::get($sessionData, 'subscription_data'),
831 ];
832 $idempotencySeed = $paymentInstance->getIdempotencySeed();
833 $idempotencyKey = $idempotencySeed
834 ? 'fct_stripe_sub_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
835 : null;
836
837 $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
838 'Idempotency-Key' => $idempotencyKey
839 ]);
840
841 if (is_wp_error($session)) {
842 return $session;
843 }
844
845 $subscriptionModel->update([
846 'vendor_customer_id' => $stripeCustomer['id']
847 ]);
848
849 $transaction->update([
850 'vendor_charge_id' => Arr::get($session, 'payment_intent', Arr::get($session, 'id')),
851 'meta' => array_merge($transaction->meta ?? [], [
852 'session_id' => $session['id']
853 ])
854 ]);
855
856 return [
857 'status' => 'success',
858 'nextAction' => 'stripe',
859 'actionName' => 'redirect',
860 'message' => __('Redirecting to Stripe checkout...', 'fluent-cart'),
861 'response' => $session,
862 'payment_args' => array_merge($paymentArgs, [
863 'checkout_url' => $session['url'],
864 'session_id' => $session['id']
865 ])
866 ];
867 }
868
869 }
870