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

1,708 lines 70.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\StripeGateway;
4
5 use FluentCart\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 * Stripe Checkout caps a session at 100 line items. Kept below it with room
17 * for the tax and reconciliation lines this builder may append.
18 */
19 const MAX_HOSTED_LINE_ITEMS = 90;
20
21 /**
22 * The return URL handed to Stripe for an onsite confirm.
23 *
24 * Onsite normally never navigates (`redirect: 'if_required'`), but an
25 * issuer that forces a full 3DS redirect sends the buyer here. Deliberately
26 * unfiltered: this is a machine contract dispatched by core WebRoutes to
27 * the fluent_cart_action_fct_stripe_onsite_return action, which confirms
28 * before the buyer is sent anywhere.
29 *
30 * @param \FluentCart\App\Models\OrderTransaction $transaction
31 * @return string
32 */
33 public static function getOnsiteGatewayReturnUrl($transaction)
34 {
35 return site_url('?fluent-cart=fct_stripe_onsite_return&trx_hash=' . $transaction->uuid);
36 }
37
38 /**
39 * The return URL handed to Stripe for hosted checkout sessions.
40 *
41 * Deliberately unfiltered: this URL is a machine contract dispatched by
42 * core WebRoutes to the fluent_cart_action_fct_stripe_hosted action,
43 * which confirms the session. The buyer's real destination —
44 * fluent_cart/payment/success_url — is applied AFTER confirmation, in
45 * the hosted-return redirect.
46 *
47 * @param \FluentCart\App\Models\OrderTransaction $transaction
48 * @return string
49 */
50 public static function getHostedGatewayReturnUrl($transaction)
51 {
52 return site_url('?fluent-cart=fct_stripe_hosted&trx_hash=' . $transaction->uuid);
53 }
54
55 public function handleSubscription(PaymentInstance $paymentInstance, $paymentArgs)
56 {
57 $stripeSettings = new StripeSettingsBase();
58 $checkoutMode = $stripeSettings->get('checkout_mode') ?? 'onsite';
59
60 // If hosted mode, create Checkout Session for subscription
61 if ($checkoutMode === 'hosted') {
62 return $this->handleHostedSubscriptionCheckout($paymentInstance, $paymentArgs);
63 }
64
65 // Original onsite subscription flow
66 $orderType = $paymentInstance->order->type;
67 $fcCustomer = $paymentInstance->order->customer;
68 $billingAddress = $paymentInstance->order->billing_address;
69
70 $subscriptionModel = $paymentInstance->subscription;
71
72 if (!$subscriptionModel) {
73 return new \WP_Error('no_subscription', __('No subscription found.', 'fluent-cart'));
74 }
75
76 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($paymentInstance->order->customer);
77
78 if (is_wp_error($stripeCustomer)) {
79 return $stripeCustomer;
80 }
81
82 $feeTotal = $orderType !== 'renewal' ? (int)$paymentInstance->order->fee_total : 0;
83 $initialAmount = (int)$subscriptionModel->signup_fee + $paymentInstance->getExtraAddonAmount() + $feeTotal;
84
85 if ($orderType == 'renewal') {
86 $stripePlan = Plan::getStripePricing([
87 'order_id' => $subscriptionModel->parent_order_id,
88 'product_id' => $subscriptionModel->product_id,
89 'variation_id' => $subscriptionModel->variation_id,
90 'billing_interval' => $subscriptionModel->billing_interval,
91 'recurring_total' => $subscriptionModel->getCurrentRenewalAmount(),
92 'currency' => $paymentInstance->order->currency,
93 'trial_days' => $subscriptionModel->getReactivationTrialDays(), // No trial for renewals
94 'interval_count' => 1 // per month / year / week
95 ]);
96
97 $initialAmount = 0;
98 } else {
99 $stripePlan = Plan::getStripePricing([
100 'order_id' => $subscriptionModel->parent_order_id,
101 'product_id' => $subscriptionModel->product_id,
102 'variation_id' => $subscriptionModel->variation_id,
103 'billing_interval' => $subscriptionModel->billing_interval,
104 'recurring_total' => $subscriptionModel->recurring_total,
105 'currency' => $paymentInstance->order->currency,
106 'trial_days' => (int)$subscriptionModel->trial_days,
107 'interval_count' => 1 // per month / year / week
108 ]);
109 }
110
111 if (is_wp_error($stripePlan)) {
112 return $stripePlan;
113 }
114
115 $stripeSubscriptionData = [
116 'customer' => Arr::get($stripeCustomer, 'id', ''),
117 'payment_behavior' => 'default_incomplete',
118 'payment_settings' => [
119 'save_default_payment_method' => 'on_subscription'
120 ],
121 'items' => [
122 [
123 'plan' => $stripePlan['id'],
124 'quantity' => $subscriptionModel->quantity ?: 1,
125 ]
126 ],
127 'expand' => [
128 'latest_invoice.confirmation_secret',
129 'pending_setup_intent'
130 ],
131 'metadata' => apply_filters('fluent_cart/payments/stripe_metadata_subscription', [
132 'fct_ref_id' => $paymentInstance->order->uuid,
133 'email' => $paymentInstance->order->customer->email,
134 'name' => $paymentInstance->order->full_name,
135 'subscription_item' => $subscriptionModel->item_name,
136 'order_reference' => 'fct_order_id_' . $paymentInstance->order->id,
137 ], [
138 'order' => $paymentInstance->order,
139 'transaction' => $paymentInstance->transaction,
140 'subscription' => $subscriptionModel
141 ]),
142 ];
143
144 if (Arr::get($stripePlan, 'trial_period_days')) {
145 // Anchor trial_end to a STABLE point (the charge transaction's creation time,
146 // which is preserved across re-submissions) instead of "now". A volatile
147 // trial_end would make a retried create send a different body, and Stripe
148 // rejects a reused Idempotency-Key whose parameters changed (400), bricking
149 // the order for the key's 24h lifetime. Anchoring keeps retries byte-identical.
150 $trialDays = (int) Arr::get($stripePlan, 'trial_period_days');
151 $anchorTs = $paymentInstance->transaction && $paymentInstance->transaction->created_at
152 ? strtotime($paymentInstance->transaction->created_at . ' UTC')
153 : time();
154 $trialEnd = strtotime('+' . $trialDays . ' days', $anchorTs);
155 // Stripe requires trial_end in the future; only a stale late retry could fall
156 // behind, and that order would already carry a fresh transaction/key anyway.
157 if ($trialEnd <= time() + MINUTE_IN_SECONDS) {
158 $trialEnd = strtotime('+' . $trialDays . ' days');
159 }
160 $stripeSubscriptionData['trial_end'] = $trialEnd;
161 }
162
163 // Maybe we have initial amount
164 if ($initialAmount) {
165 $addonPrice = Plan::getOneTimeAddonPrice([
166 'product_id' => $subscriptionModel->product_id,
167 'currency' => $paymentInstance->order->currency,
168 'amount' => (int)$initialAmount,
169 'variation_id' => $subscriptionModel->variation_id,
170 'order_id' => $subscriptionModel->parent_order_id,
171 ]);
172
173 if (is_wp_error($addonPrice)) {
174 return $addonPrice;
175 }
176
177 $stripeSubscriptionData['add_invoice_items'] = [
178 [
179 'price' => $addonPrice['id'],
180 'quantity' => 1
181 ]
182 ];
183 }
184
185 if ($expireAt = $paymentInstance->getSubscriptionCancelAtTimeStamp()) {
186 // $stripeSubscriptionData['cancel_at'] = $expireAt;
187 }
188
189 // Duplicate-charge defense — key construction contract in
190 // .claude/skills/coding-rules/payment-idempotency.md. Seed dedupes duplicates
191 // and frees retries; fingerprint = charge-material params so an edited order
192 // gets a fresh key instead of a same-key/changed-parameters 400 (the abandoned
193 // incomplete subscription auto-expires). Params, not transaction->total: a
194 // recurring coupon can change the plan while the first charge stays $0.
195 // Metadata excluded — volatile filters must not change the key on a duplicate.
196 // Guard runs here, after the create body is built, so it can compare it.
197 $existingRemoteSubscription = $this->guardExistingRemoteSubscription($subscriptionModel, $paymentInstance->order, $stripeSubscriptionData);
198 if (is_wp_error($existingRemoteSubscription)) {
199 return $existingRemoteSubscription;
200 }
201
202 $idempotencyFingerprint = [
203 'customer' => Arr::get($stripeSubscriptionData, 'customer'),
204 'items' => Arr::get($stripeSubscriptionData, 'items'),
205 'add_invoice_items' => Arr::get($stripeSubscriptionData, 'add_invoice_items'),
206 'trial_end' => Arr::get($stripeSubscriptionData, 'trial_end'),
207 'replaces' => (string)Arr::get(
208 (array)$subscriptionModel->config,
209 'stripe_replaced_vendor_sub_id',
210 ''
211 ),
212 ];
213 $idempotencySeed = $paymentInstance->getIdempotencySeed();
214 $idempotencyKey = $idempotencySeed
215 ? 'fct_stripe_sub_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
216 : null;
217
218 if ($existingRemoteSubscription) {
219 $stripeSubscription = $existingRemoteSubscription;
220 } else {
221 $stripeSubscription = (new API())->createStripeObject('subscriptions', $stripeSubscriptionData, 'current', [
222 'Idempotency-Key' => $idempotencyKey
223 ]);
224 }
225
226 if (is_wp_error($stripeSubscription)) {
227 return $stripeSubscription;
228 }
229
230 // A guard-reused sub carries an expanded payment_intent object here.
231 $vendorChargeId = Arr::get($stripeSubscription, 'latest_invoice.payment_intent');
232 if (is_array($vendorChargeId)) {
233 $vendorChargeId = Arr::get($vendorChargeId, 'id');
234 }
235 if (!$vendorChargeId) {
236 $vendorChargeId = Arr::get($stripeSubscription, 'pending_setup_intent.id');
237 }
238
239 if ($vendorChargeId) {
240 $paymentInstance->transaction->update(['vendor_charge_id' => $vendorChargeId]);
241 }
242
243 $vendorSubscriptionId = Arr::get($stripeSubscription, 'id');
244
245 $subscriptionUpdateFields = [
246 'vendor_subscription_id' => $vendorSubscriptionId,
247 'vendor_customer_id' => $stripeSubscription['customer']
248 ];
249
250 $subscriptionModel->update($subscriptionUpdateFields);
251
252 if ($orderType == 'renewal' && Arr::get($stripePlan, 'trial_period_days', 0) > 0) {
253 $subscriptionModel->mergeConfig(['is_trial_days_simulated' => 'yes']);
254 }
255
256 if ($stripeSubscription['pending_setup_intent'] != null) {
257 $paymentArgs['vendor_subscription_info'] = [
258 'type' => 'setup',
259 'clientSecret' => Arr::get($stripeSubscription, 'pending_setup_intent.client_secret'),
260 'trx_hash' => $paymentInstance->transaction->uuid,
261 ];
262 } else {
263 $paymentArgs['vendor_subscription_info'] = [
264 'type' => 'payment',
265 'clientSecret' => Arr::get($stripeSubscription, 'latest_invoice.confirmation_secret.client_secret')
266 ];
267 }
268
269 $customerData = [
270 'name' => $fcCustomer->first_name . ' ' . $fcCustomer->last_name,
271 'email' => $fcCustomer->email,
272 'address_1' => $billingAddress->address_1,
273 'address_2' => $billingAddress->address_2,
274 'city' => $billingAddress->city,
275 'state' => $billingAddress->state,
276 'postcode' => $billingAddress->postcode,
277 'country' => $billingAddress->country
278 ];
279
280 return [
281 'nextAction' => 'stripe',
282 'actionName' => 'custom',
283 'status' => 'success',
284 'message' => __('Order has been placed successfully', 'fluent-cart'),
285 'payment_args' => $paymentArgs,
286 'response' => $stripeSubscription,
287 'fc_customer' => $customerData
288 ];
289 }
290
291 /**
292 * A retryable checkout can arrive with a live Stripe subscription already
293 * attached — the previous create succeeded but its confirm/webhook never
294 * landed, and a changed cart mints a fresh idempotency key, so the key alone
295 * cannot stop a second create. A second create bills the customer on a
296 * subscription the store cannot see or cancel.
297 *
298 * Ownership discriminator: metadata.fct_ref_id (stamped at create) must match
299 * $order->uuid before the guard reuses or cancels anything. A non-matching sub
300 * belongs to another flow — on renewal, the previous cycle's subscription that
301 * SubscriptionRenewalHandler cancels only after payment succeeds — so the
302 * guard must leave it alone. active/trialing blocks regardless of owner: the
303 * customer must never be charged beside a live subscription. One exception:
304 * an owned trialing sub with no payment method and a still-confirmable
305 * pending_setup_intent is handed back for reuse — a $0 first invoice skips
306 * `incomplete`, so a failed card setup leaves the sub trialing, not dead.
307 *
308 * Owned incomplete the buyer can still confirm is handed back for reuse —
309 * `incomplete` is the normal status for the whole confirm (3DS) window, and
310 * cancelling it voids the PaymentIntent mid-confirmation
311 * (payment_intent_unexpected_state). Owned but dead is cancelled, the
312 * cancelled id persisted to subscription config
313 * (stripe_replaced_vendor_sub_id) and the local vendor id cleared; the
314 * caller folds the persisted id into the idempotency fingerprint so the
315 * recreate cannot replay Stripe's 24h-cached response for the deleted sub
316 * (the pending transaction's seed has not rolled). Persisted, not
317 * request-local, so a retry after an ambiguously failed recreate computes
318 * the same key and Stripe's idempotent replay recovers the unrecorded sub.
319 * A failed cancel fails CLOSED, like guardExistingPaymentIntent.
320 *
321 * @param array $requestData create body for reuse comparison; empty (hosted
322 * Checkout Session) means nothing is reusable.
323 * @return array|\WP_Error|null reusable remote sub, stop, or create fresh
324 */
325 private function guardExistingRemoteSubscription($subscriptionModel, $order, $requestData = [])
326 {
327 $existingVendorSubId = $subscriptionModel->vendor_subscription_id;
328 if (!$existingVendorSubId || strpos($existingVendorSubId, 'sub_') !== 0) {
329 return null;
330 }
331
332 $remoteSub = (new API())->getStripeObject('subscriptions/' . $existingVendorSubId, [
333 'expand' => [
334 'latest_invoice.confirmation_secret',
335 'latest_invoice.payment_intent',
336 'pending_setup_intent'
337 ]
338 ], 'current');
339
340 if (is_wp_error($remoteSub)) {
341 return null;
342 }
343
344 $remoteStatus = Arr::get($remoteSub, 'status');
345
346 if (in_array($remoteStatus, ['active', 'trialing'], true)) {
347 // A $0 first invoice skips `incomplete`: the sub is `trialing` while the
348 // card is still being set up via pending_setup_intent, and a failed 3DS
349 // leaves it trialing with no payment method. Hand the setup intent back
350 // so the buyer's retry can attach a card instead of being blocked.
351 if (
352 $remoteStatus === 'trialing'
353 && !Arr::get($remoteSub, 'default_payment_method')
354 && Arr::get($remoteSub, 'metadata.fct_ref_id') === $order->uuid
355 && $this->remoteSubscriptionIsConfirmable($remoteSub, $requestData)
356 ) {
357 return $remoteSub;
358 }
359
360 (new StripeSubscriptions())->reSyncSubscriptionFromRemote($subscriptionModel);
361 return new \WP_Error(
362 'stripe_subscription_already_active',
363 __('Subscription is already active. Please refresh this page to see the status instead of trying again.', 'fluent-cart')
364 );
365 }
366
367 if (Arr::get($remoteSub, 'metadata.fct_ref_id') !== $order->uuid) {
368 return null;
369 }
370
371 // Already canceled remotely (e.g. an earlier guard cancel whose replacement
372 // create failed before it was recorded): mark it replaced and clear the
373 // local id — the retry then recomputes the post-cancel key, and Stripe's
374 // idempotent replay recovers any unrecorded replacement.
375 if (in_array($remoteStatus, ['canceled', 'incomplete_expired'], true)) {
376 $subscriptionModel->mergeConfig(['stripe_replaced_vendor_sub_id' => $existingVendorSubId]);
377 $subscriptionModel->update(['vendor_subscription_id' => '']);
378 return null;
379 }
380
381 if (!in_array($remoteStatus, ['incomplete', 'unpaid'], true)) {
382 return null;
383 }
384
385 if ($remoteStatus === 'incomplete' && $this->remoteSubscriptionIsConfirmable($remoteSub, $requestData)) {
386 return $remoteSub;
387 }
388
389 $cancelResponse = (new API())->deleteStripeObject('subscriptions/' . $existingVendorSubId, [], 'current');
390 if (is_wp_error($cancelResponse)) {
391 fluent_cart_warning_log(
392 'Stripe stale ' . $remoteStatus . ' subscription cancel failed',
393 $cancelResponse->get_error_message() . ' (' . $existingVendorSubId . ')',
394 [
395 'module_type' => 'FluentCart\App\Models\Subscription',
396 'module_id' => $subscriptionModel->id,
397 'module_name' => 'subscription',
398 'log_type' => 'api'
399 ]
400 );
401
402 return new \WP_Error(
403 'stripe_subscription_cancel_failed',
404 __('We could not update your previous subscription attempt. Please wait a moment and try again.', 'fluent-cart')
405 );
406 }
407
408 // Marker before id-clear: a crash between the two leaves the id pointing at
409 // the now-canceled sub, which the canceled branch above converges on retry.
410 $subscriptionModel->mergeConfig(['stripe_replaced_vendor_sub_id' => $existingVendorSubId]);
411 $subscriptionModel->update(['vendor_subscription_id' => '']);
412
413 return null;
414 }
415
416 /**
417 * The intent (payment or setup) must still be browser-confirmable AND the
418 * subscription must bill exactly what this attempt would create — a cart
419 * edited between attempts mints new Stripe price ids, and reusing the old
420 * subscription would charge the wrong amount.
421 */
422 private function remoteSubscriptionIsConfirmable($remoteSub, $requestData)
423 {
424 if (!$requestData) {
425 return false;
426 }
427
428 $confirmable = ['requires_payment_method', 'requires_confirmation', 'requires_action'];
429
430 $intentStatus = Arr::get($remoteSub, 'latest_invoice.payment_intent.status');
431 $clientSecret = Arr::get($remoteSub, 'latest_invoice.confirmation_secret.client_secret');
432 if (!$intentStatus) {
433 $intentStatus = Arr::get($remoteSub, 'pending_setup_intent.status');
434 $clientSecret = Arr::get($remoteSub, 'pending_setup_intent.client_secret');
435 }
436
437 if (!in_array($intentStatus, $confirmable, true) || !$clientSecret) {
438 return false;
439 }
440
441 return $this->subscriptionChargeMaterialMatches($remoteSub, $requestData);
442 }
443
444 /**
445 * Recurring items are compared against `items.data`; one-off signup/addon
446 * lines live only on the first invoice, so they are compared against
447 * `latest_invoice.lines` when the invoice carries any — a $0 trial invoice
448 * often carries none, and falling back to the item comparison there keeps
449 * trial checkouts reusable instead of cancelling a live intent.
450 */
451 private function subscriptionChargeMaterialMatches($remoteSub, $requestData)
452 {
453 $wantedItems = [];
454 foreach ((array)Arr::get($requestData, 'items', []) as $item) {
455 $priceId = Arr::get($item, 'plan', Arr::get($item, 'price'));
456 $wantedItems[] = (string)$priceId . ':' . (int)(Arr::get($item, 'quantity') ?: 1);
457 }
458
459 $remoteItems = [];
460 foreach ((array)Arr::get($remoteSub, 'items.data', []) as $item) {
461 $priceId = Arr::get($item, 'price.id', Arr::get($item, 'plan.id'));
462 $remoteItems[] = (string)$priceId . ':' . (int)(Arr::get($item, 'quantity') ?: 1);
463 }
464
465 sort($wantedItems);
466 sort($remoteItems);
467
468 if (!$wantedItems || $wantedItems !== $remoteItems) {
469 return false;
470 }
471
472 $remoteLines = [];
473 foreach ((array)Arr::get($remoteSub, 'latest_invoice.lines.data', []) as $line) {
474 $remoteLines[] = (string)Arr::get($line, 'price.id', Arr::get($line, 'plan.id'));
475 }
476
477 if (!$remoteLines) {
478 return true;
479 }
480
481 $wantedLines = [];
482 foreach ((array)Arr::get($requestData, 'items', []) as $item) {
483 $wantedLines[] = (string)Arr::get($item, 'plan', Arr::get($item, 'price'));
484 }
485 foreach ((array)Arr::get($requestData, 'add_invoice_items', []) as $item) {
486 $wantedLines[] = (string)Arr::get($item, 'price');
487 }
488
489 $remoteLines = array_values(array_unique($remoteLines));
490 $wantedLines = array_values(array_unique($wantedLines));
491
492 sort($remoteLines);
493 sort($wantedLines);
494
495 return $wantedLines === $remoteLines;
496 }
497
498 /**
499 * One-time analogue of guardExistingRemoteSubscription(). A resubmit whose
500 * charge-material params changed (or whose key aged past Stripe's 24h window)
501 * would mint a second PaymentIntent while the first stays confirmable in any
502 * stale tab — and a charge on that orphan is dropped by the webhook with no
503 * local record. Succeeded remote: record the payment and stop the re-charge.
504 * In-flight (processing / requires_capture): stop and let it settle.
505 * Confirmable with matching charge-material params: reuse it. Mismatched:
506 * cancel it so exactly one confirmable intent exists. Lookup/cancel failures
507 * fail CLOSED (WP_Error, retryable) rather than falling through to create —
508 * otherwise a transient Stripe error would let a second intent get created
509 * while the first stays confirmable, reopening the orphan path this guards.
510 *
511 * Returns null (create fresh), the reusable intent array, a redirect response
512 * array (already-succeeded — checkout's GET render has no order-status check,
513 * so the caller must push the browser to the receipt page itself rather than
514 * ask the customer to refresh), or WP_Error (stop, retryable).
515 */
516 private function guardExistingPaymentIntent(PaymentInstance $paymentInstance, $intentData)
517 {
518 $transaction = $paymentInstance->transaction;
519 $existingIntentId = $transaction->vendor_charge_id;
520
521 if (!$existingIntentId || strpos($existingIntentId, 'pi_') !== 0) {
522 return null;
523 }
524
525 $existingIntent = (new API())->getStripeObject('payment_intents/' . $existingIntentId, [
526 'expand' => ['latest_charge']
527 ], 'current');
528
529 if (is_wp_error($existingIntent)) {
530 fluent_cart_warning_log(
531 'Stripe existing payment intent lookup failed',
532 $existingIntent->get_error_message() . ' (' . $existingIntentId . ')',
533 [
534 'module_name' => 'order',
535 'module_id' => $transaction->order_id,
536 'log_type' => 'api'
537 ]
538 );
539 return new \WP_Error(
540 'stripe_payment_intent_lookup_failed',
541 __('We could not verify your previous payment attempt. Please wait a moment and try again.', 'fluent-cart')
542 );
543 }
544
545 $intentStatus = Arr::get($existingIntent, 'status');
546
547 if ('succeeded' === $intentStatus) {
548 $charge = Arr::get($existingIntent, 'latest_charge', []);
549 (new Confirmations())->confirmPaymentSuccessByCharge($transaction, [
550 'charge' => is_array($charge) ? $charge : [],
551 'intent_id' => $existingIntentId
552 ]);
553
554 // Local state is already synced to success — send the browser straight
555 // to the receipt instead of erroring and telling the customer to refresh
556 // a page that has no idea their order is paid.
557 return [
558 'fct_redirect' => true,
559 'status' => 'success',
560 'redirect_to' => $transaction->getSuccessUrl(),
561 'message' => __('Your payment has already been processed. Redirecting to your order...', 'fluent-cart')
562 ];
563 }
564
565 if (in_array($intentStatus, ['processing', 'requires_capture'], true)) {
566 return new \WP_Error(
567 'stripe_payment_in_flight',
568 __('Your previous payment attempt is still being processed. Please wait a moment before trying again — do not resubmit.', 'fluent-cart')
569 );
570 }
571
572 if (in_array($intentStatus, ['requires_payment_method', 'requires_confirmation', 'requires_action'], true)) {
573 $chargeMaterialMatches = (int)Arr::get($existingIntent, 'amount') === (int)Arr::get($intentData, 'amount')
574 && strtolower((string)Arr::get($existingIntent, 'currency')) === strtolower((string)Arr::get($intentData, 'currency'))
575 && Arr::get($existingIntent, 'customer') === Arr::get($intentData, 'customer');
576
577 if ($chargeMaterialMatches) {
578 return $existingIntent;
579 }
580
581 $cancelResponse = (new API())->createStripeObject('payment_intents/' . $existingIntentId . '/cancel', [], 'current');
582 if (is_wp_error($cancelResponse)) {
583 fluent_cart_warning_log(
584 'Stripe stale payment intent cancel failed',
585 $cancelResponse->get_error_message() . ' (' . $existingIntentId . ')',
586 [
587 'module_name' => 'order',
588 'module_id' => $transaction->order_id,
589 'log_type' => 'api'
590 ]
591 );
592 return new \WP_Error(
593 'stripe_payment_intent_cancel_failed',
594 __('We could not update your previous payment attempt. Please wait a moment and try again.', 'fluent-cart')
595 );
596 }
597 }
598
599 return null;
600 }
601
602
603 /**
604 * Handle single payment for stripe (onsite or hosted)
605 *
606 * @return \WP_Error|array
607 */
608 /**
609 * Zero-payable system (auto-charged) subscription checkout — a free trial with
610 * nothing to pay today. A $0 PaymentIntent is invalid, so the card is vaulted
611 * via a SetupIntent instead; confirmation (Confirmations::confirmSetupIntent)
612 * persists the token, completes the $0 order, and activates the trial. The
613 * trial-end invoice is then charged off-session like any other system renewal.
614 *
615 * Consent is REQUIRED here (not just disclosed): without a saved card the
616 * trial can never bill, so a checkout without the consent flag is rejected.
617 */
618 public function handleSetupOnlyPayment(PaymentInstance $paymentInstance, $paymentArgs = [])
619 {
620 $order = $paymentInstance->order;
621 $transaction = $paymentInstance->transaction;
622 $fcCustomer = $order->customer;
623 $billingAddress = $order->billing_address;
624
625 $consent = sanitize_text_field(App::request()->get('_fct_system_consent', ''));
626 if ($consent !== 'yes') {
627 return new \WP_Error(
628 'consent_required',
629 __('Please agree to save your payment method for automatic renewal charges to start this subscription.', 'fluent-cart')
630 );
631 }
632
633 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($fcCustomer);
634 if (is_wp_error($stripeCustomer)) {
635 return $stripeCustomer;
636 }
637
638 $intentData = [
639 'customer' => $stripeCustomer['id'],
640 'usage' => 'off_session',
641 'automatic_payment_methods' => ['enabled' => 'true'],
642 'metadata' => apply_filters('fluent_cart/payments/stripe_metadata_onetime', [
643 'fct_ref_id' => $order->uuid,
644 'Name' => $fcCustomer->full_name,
645 'Email' => $fcCustomer->email,
646 'order_reference' => 'fct_order_id_' . $order->id,
647 ], [
648 'order' => $order,
649 'transaction' => $transaction
650 ]),
651 ];
652
653 $intent = (new API())->createStripeObject('setup_intents', $intentData);
654
655 if (is_wp_error($intent)) {
656 return $intent;
657 }
658
659 // confirmSetupIntent() resolves the transaction by this id (and clears it
660 // after confirmation — a setup intent id is not a charge id).
661 $transaction->update([
662 'vendor_charge_id' => $intent['id']
663 ]);
664
665 $paymentArgs['public_key'] = (new StripeSettingsBase())->getPublicKey();
666 // The AJAX confirm endpoint requires the transaction hash for seti_ ids.
667 $paymentArgs['trx_hash'] = $transaction->uuid;
668
669 $customerData = [
670 'name' => $fcCustomer->first_name . ' ' . $fcCustomer->last_name,
671 'email' => $fcCustomer->email,
672 'address_1' => $billingAddress ? $billingAddress->address_1 : '',
673 'address_2' => $billingAddress ? $billingAddress->address_2 : '',
674 'city' => $billingAddress ? $billingAddress->city : '',
675 'state' => $billingAddress ? $billingAddress->state : '',
676 'postcode' => $billingAddress ? $billingAddress->postcode : '',
677 'country' => $billingAddress ? $billingAddress->country : ''
678 ];
679
680 return [
681 'status' => 'success',
682 'nextAction' => 'stripe',
683 'actionName' => 'custom',
684 'message' => __('Order has been placed successfully', 'fluent-cart'),
685 'response' => $intent,
686 'payment_args' => $paymentArgs,
687 'fc_customer' => $customerData
688 ];
689 }
690
691 /**
692 * Hosted-checkout counterpart to handleSetupOnlyPayment() — hosted mode never
693 * loads Stripe.js/Elements, so a zero-payable system-subscription checkout
694 * redirects to a Checkout Session in `mode: setup` instead of a client-side
695 * SetupIntent. The session's auto-created setup_intent id is stored as
696 * vendor_charge_id so setup_intent.succeeded / confirmByCheckoutSession
697 * resolve the transaction exactly like the onsite path.
698 */
699 public function handleHostedSetupOnlyCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
700 {
701 $order = $paymentInstance->order;
702 $transaction = $paymentInstance->transaction;
703 $fcCustomer = $order->customer;
704
705 $consent = sanitize_text_field(App::request()->get('_fct_system_consent', ''));
706 if ($consent !== 'yes') {
707 return new \WP_Error(
708 'consent_required',
709 __('Please agree to save your payment method for automatic renewal charges to start this subscription.', 'fluent-cart')
710 );
711 }
712
713 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($fcCustomer);
714 if (is_wp_error($stripeCustomer)) {
715 return $stripeCustomer;
716 }
717
718 $transactionCurrency = $transaction->currency;
719
720 $sessionData = [
721 'customer' => $stripeCustomer['id'],
722 'client_reference_id' => $order->uuid,
723 'mode' => 'setup',
724 'currency' => strtolower($transactionCurrency),
725 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
726 'cancel_url' => StripeHelper::getCancelUrl(),
727 'metadata' => [
728 'fct_ref_id' => $order->uuid,
729 'transaction_hash' => $transaction->uuid,
730 'order_reference' => 'fct_order_id_' . $order->id,
731 ],
732 ];
733
734 $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $sessionData, [
735 'order' => $order,
736 'transaction' => $transaction
737 ]);
738
739 // Same duplicate-charge defense as every other Stripe create path.
740 $idempotencyFingerprint = [
741 'customer' => Arr::get($sessionData, 'customer'),
742 'mode' => Arr::get($sessionData, 'mode'),
743 'currency' => Arr::get($sessionData, 'currency'),
744 ];
745 $idempotencySeed = $paymentInstance->getIdempotencySeed();
746 $idempotencyKey = $idempotencySeed
747 ? 'fct_stripe_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
748 : null;
749
750 $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
751 'Idempotency-Key' => $idempotencyKey
752 ]);
753
754 if (is_wp_error($session)) {
755 return $session;
756 }
757
758 // confirmSetupIntent() resolves the transaction by this id (and clears it
759 // after confirmation — a setup intent id is not a charge id).
760 $transaction->update([
761 'vendor_charge_id' => Arr::get($session, 'setup_intent'),
762 'meta' => array_merge($transaction->meta ?? [], [
763 'session_id' => $session['id']
764 ])
765 ]);
766
767 return [
768 'status' => 'success',
769 'nextAction' => 'stripe',
770 'actionName' => 'redirect',
771 'message' => __('Redirecting to Stripe checkout...', 'fluent-cart'),
772 'response' => $session,
773 'payment_args' => array_merge($paymentArgs, [
774 'checkout_url' => $session['url'],
775 'session_id' => $session['id']
776 ])
777 ];
778 }
779
780 public function handleSinglePayment(PaymentInstance $paymentInstance, $paymentArgs = [])
781 {
782 $stripeSettings = new StripeSettingsBase();
783 $checkoutMode = $stripeSettings->get('checkout_mode') ?? 'onsite';
784
785 if ($checkoutMode === 'hosted') {
786 return $this->handleHostedCheckout($paymentInstance, $paymentArgs);
787 }
788
789 // Original onsite payment flow
790 $order = $paymentInstance->order;
791 $transaction = $paymentInstance->transaction;
792 $fcCustomer = $paymentInstance->order->customer;
793 $billingAddress = $order->billing_address;
794
795 $transactionCurrency = $transaction->currency;
796 $intentAmount = (int)$transaction->total;
797
798 if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
799 $intentAmount = (int)($intentAmount / 100);
800 }
801
802 $intentData = [
803 'amount' => $intentAmount,
804 'currency' => $transactionCurrency,
805 'automatic_payment_methods' => ['enabled' => 'true'],
806 'metadata' => apply_filters('fluent_cart/payments/stripe_metadata_onetime', [
807 'fct_ref_id' => $order->uuid,
808 'Name' => $order->customer->full_name,
809 'Email' => $order->customer->email,
810 'order_reference' => 'fct_order_id_' . $paymentInstance->order->id,
811 ], [
812 'order' => $order,
813 'transaction' => $transaction
814 ]),
815 ];
816
817 $itemCount = 1;
818 foreach($paymentInstance->order->order_items as $item) {
819 $intentData['metadata']['item ' . $itemCount] = 'Name: ' . $item->title . ', ' . 'Qty: ' . $item->quantity . ', Price: ' . Helper::toDecimal($item->line_total, false, null, true, true, false);
820 if (count($intentData['metadata']) > 49) {
821 break;
822 }
823 $itemCount++;
824 }
825
826 if (!empty($paymentArgs['customer'])) {
827 $intentData['customer'] = $paymentArgs['customer'];
828 } else {
829 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($order->customer);
830 if (is_wp_error($stripeCustomer)) {
831 return $stripeCustomer;
832 }
833 $intentData['customer'] = $stripeCustomer['id'];
834 }
835
836 if (!empty($paymentArgs['setup_future_usage'])) {
837 $intentData['setup_future_usage'] = $paymentArgs['setup_future_usage'];
838 }
839
840 $paymentArgs['public_key'] = (new StripeSettingsBase())->getPublicKey();
841
842 $intentData = apply_filters('fluent_cart/payments/stripe_onetime_intent_args', $intentData, [
843 'order' => $order,
844 'transaction' => $transaction
845 ]);
846
847 // Reuse or retire any intent this transaction already holds — the idempotency
848 // key alone cannot cover a resubmit whose charge-material params changed or
849 // whose key aged out of Stripe's 24h window.
850 $intent = $this->guardExistingPaymentIntent($paymentInstance, $intentData);
851 if (is_wp_error($intent)) {
852 return $intent;
853 }
854
855 if (!empty($intent['fct_redirect'])) {
856 return $intent;
857 }
858
859 if (!$intent) {
860 // Same duplicate-charge defense for one-time onsite payments. Customer is in
861 // the fingerprint because a guest editing their email between attempts maps to
862 // a different Stripe customer — same key there would 400 for the key's 24h
863 // lifetime. Built AFTER the intent-args filter so filtered amounts are what
864 // get fingerprinted.
865 $idempotencyFingerprint = [
866 'amount' => Arr::get($intentData, 'amount'),
867 'currency' => Arr::get($intentData, 'currency'),
868 'customer' => Arr::get($intentData, 'customer'),
869 ];
870 $idempotencySeed = $paymentInstance->getIdempotencySeed();
871 $idempotencyKey = $idempotencySeed
872 ? 'fct_stripe_pi_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
873 : null;
874
875 $intent = (new API())->createStripeObject('payment_intents', $intentData, 'current', [
876 'Idempotency-Key' => $idempotencyKey
877 ]);
878
879 if (is_wp_error($intent)) {
880 return $intent;
881 }
882
883 $transaction->update([
884 'vendor_charge_id' => $intent['id']
885 ]);
886 }
887
888 $customerData = [
889 'name' => $fcCustomer->first_name . ' ' . $fcCustomer->last_name,
890 'email' => $fcCustomer->email,
891 'address_1' => $billingAddress->address_1,
892 'address_2' => $billingAddress->address_2,
893 'city' => $billingAddress->city,
894 'state' => $billingAddress->state,
895 'postcode' => $billingAddress->postcode,
896 'country' => $billingAddress->country
897 ];
898
899 return [
900 'status' => 'success',
901 'nextAction' => 'stripe',
902 'actionName' => 'custom',
903 'message' => __('Order has been placed successfully', 'fluent-cart'),
904 'response' => $intent,
905 'payment_args' => $paymentArgs,
906 'fc_customer' => $customerData
907 ];
908 }
909
910
911 private function handleHostedCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
912 {
913 $order = $paymentInstance->order;
914 $transaction = $paymentInstance->transaction;
915 $fcCustomer = $order->customer;
916 $billingAddress = $order->billing_address;
917
918 $transactionCurrency = $transaction->currency;
919 $chargeAmount = (int)$transaction->total;
920
921 if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
922 $chargeAmount = (int)($chargeAmount / 100);
923 }
924
925 // Create or get Stripe customer
926 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($fcCustomer);
927 if (is_wp_error($stripeCustomer)) {
928 return $stripeCustomer;
929 }
930
931 // Per-item breakdown when it reconciles exactly to the charge amount,
932 // otherwise the historical single aggregate line. buildHostedLineItems()
933 // returns null for anything it cannot prove sums to $chargeAmount — the
934 // fallback is always a correct charge, just a less itemised one.
935 $lineItems = $this->buildHostedLineItems($order, $transactionCurrency, $chargeAmount);
936 $usedBreakdown = $lineItems !== null;
937
938 if (!$usedBreakdown) {
939 $lineItems = $this->aggregateHostedLineItems($order, $transactionCurrency, $chargeAmount);
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 $submitType = (new StripeSettingsBase())->getSubmitType();
966 if ($submitType && in_array($submitType, ['auto', 'book', 'donate', 'pay'], true)) {
967 $sessionData['submit_type'] = $submitType;
968 }
969
970 $itemCount = 1;
971 foreach($order->order_items as $item) {
972 $sessionData['metadata']['item ' . $itemCount] = 'Name: ' . $item->title . ', ' . 'Qty: ' . $item->quantity . ', Price: ' . Helper::toDecimal($item->line_total, false, null, true, true, false);
973 if (count($sessionData['metadata']) > 49) {
974 break;
975 }
976
977 $itemCount++;
978 }
979
980 // Kept unfiltered so the aggregate retry below can re-derive the body from
981 // the same base rather than editing a filtered one underneath its author.
982 $baseSessionData = $sessionData;
983
984 $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $sessionData, [
985 'order' => $order,
986 'transaction' => $transaction
987 ]);
988
989 $idempotencySeed = $paymentInstance->getIdempotencySeed();
990 $idempotencyKey = $this->hostedSessionIdempotencyKey($idempotencySeed, $sessionData);
991
992 $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
993 'Idempotency-Key' => $idempotencyKey
994 ]);
995
996 // A breakdown adds validation surface the single aggregate line does not
997 // have (line-item cap, product naming, per-item amounts). If Stripe rejects
998 // the itemised body, fall back to the aggregate rather than failing the
999 // buyer's checkout. Only a structural rejection qualifies: a transport
1000 // failure may mean the session was in fact created, and retrying that under
1001 // a different key would abandon the idempotency guarantee for no reason.
1002 if ($usedBreakdown && $this->isStripeValidationError($session)) {
1003 fluent_cart_warning_log(
1004 'Stripe checkout line-item breakdown rejected',
1005 'Stripe rejected the itemised checkout session; retrying with a single aggregate line item. Reason: ' . $session->get_error_message(),
1006 [
1007 'module_name' => 'order',
1008 'module_id' => $order->id,
1009 'log_type' => 'api'
1010 ]
1011 );
1012
1013 // Re-filter the aggregate body instead of swapping line_items inside the
1014 // filtered one: a subscriber that derives anything from line_items (per
1015 // line tax_rates, automatic_tax, its own totals) decided that against the
1016 // itemised body, and editing underneath it leaves the two disagreeing.
1017 // Rebuilt from the unfiltered base so a subscriber that appends rather
1018 // than replaces does not apply twice.
1019 $baseSessionData['line_items'] = $this->aggregateHostedLineItems($order, $transactionCurrency, $chargeAmount);
1020
1021 $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $baseSessionData, [
1022 'order' => $order,
1023 'transaction' => $transaction
1024 ]);
1025
1026 $idempotencyKey = $this->hostedSessionIdempotencyKey($idempotencySeed, $sessionData);
1027
1028 $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
1029 'Idempotency-Key' => $idempotencyKey
1030 ]);
1031 }
1032
1033 if (is_wp_error($session)) {
1034 return $session;
1035 }
1036
1037 $transaction->update([
1038 'meta' => array_merge($transaction->meta ?? [], [
1039 'session_id' => $session['id']
1040 ])
1041 ]);
1042
1043 return [
1044 'status' => 'success',
1045 'nextAction' => 'stripe',
1046 'actionName' => 'redirect',
1047 'message' => __('Redirecting to Stripe checkout...', 'fluent-cart'),
1048 'response' => $session,
1049 'payment_args' => array_merge($paymentArgs, [
1050 'checkout_url' => $session['url'],
1051 'session_id' => $session['id']
1052 ])
1053 ];
1054 }
1055
1056
1057 /**
1058 * Itemised line_items for a hosted (mode: payment) Checkout Session.
1059 *
1060 * Unlike PayPal's purchase_unit — where we declare amount.value and the
1061 * breakdown merely has to agree with it — Stripe DERIVES the session total
1062 * from line_items. There is no total to assert and no subtractive field
1063 * (order-level discounts need a Coupon object; negative unit_amount is
1064 * rejected). So the sum of what we send IS what the buyer is charged, and a
1065 * breakdown that is a cent off does not error, it mischarges.
1066 *
1067 * Everything is therefore reconciled against $chargeAmount before returning,
1068 * in wire units, and null is returned for any order this cannot prove exact.
1069 * The caller then sends a single aggregate line — always the correct amount.
1070 *
1071 * Discounts need no line of their own: order_items.line_total is already
1072 * subtotal minus discount_total (CheckoutProcessor::116), so item-level
1073 * discounts are baked into the per-unit price.
1074 *
1075 * @param \FluentCart\App\Models\Order $order
1076 * @param string $currency
1077 * @param int $chargeAmount Charge total in wire units (already divided for zero-decimal).
1078 * @return array|null Null when no exact breakdown is possible.
1079 */
1080 /**
1081 * Duplicate-charge defense for a hosted payment session: a pure duplicate
1082 * replays the key and gets the original session back, while an edited-cart
1083 * resubmit gets a fresh key instead of a same-key/changed-parameters 400.
1084 *
1085 * Derived from the FILTERED body, so a subscriber that changes what is
1086 * actually charged changes the key with it. Metadata is excluded — a
1087 * volatile metadata filter must not roll the key on a genuine duplicate.
1088 *
1089 * @param string|null $seed
1090 * @param array $sessionData
1091 * @return string|null
1092 */
1093 private function hostedSessionIdempotencyKey($seed, $sessionData)
1094 {
1095 if (!$seed) {
1096 return null;
1097 }
1098
1099 $fingerprint = [
1100 'customer' => Arr::get($sessionData, 'customer'),
1101 'line_items' => Arr::get($sessionData, 'line_items'),
1102 'mode' => Arr::get($sessionData, 'mode'),
1103 ];
1104
1105 return 'fct_stripe_cs_' . md5($seed . '|' . wp_json_encode($fingerprint));
1106 }
1107
1108 private function buildHostedLineItems($order, $currency, $chargeAmount)
1109 {
1110 $isZeroDecimal = $currency && CurrenciesHelper::isZeroDecimal($currency);
1111 $stripeCurrency = strtolower($currency);
1112
1113 $lineItems = [];
1114 $sum = 0;
1115
1116 // The relation is materialised on this request either way (the metadata
1117 // loop below walks it), so reuse it instead of issuing a second query.
1118 // Deterministic order: the idempotency fingerprint hashes line_items, so a
1119 // varying sequence would roll the key on a genuine duplicate submission.
1120 $orderItems = $order->order_items->sortBy('id')->values();
1121
1122 // Decline before building anything the cap would discard.
1123 if ($orderItems->count() > self::MAX_HOSTED_LINE_ITEMS) {
1124 return null;
1125 }
1126
1127 foreach ($orderItems as $item) {
1128 $quantity = (int) $item->quantity;
1129 if ($quantity < 1) {
1130 $quantity = 1;
1131 }
1132
1133 $lineTotal = $this->toStripeWireAmount($item->line_total, $isZeroDecimal);
1134
1135 // A zero line contributes nothing to the total and Stripe has no use
1136 // for a zero-priced row here; skipping keeps us under the item cap.
1137 if ($lineTotal <= 0) {
1138 continue;
1139 }
1140
1141 $unitAmount = intdiv($lineTotal, $quantity);
1142 if ($unitAmount <= 0) {
1143 continue;
1144 }
1145
1146 $name = $this->hostedLineItemName($item);
1147 if ($name === '') {
1148 return null; // Stripe requires a non-empty product name.
1149 }
1150
1151 if (count($lineItems) >= self::MAX_HOSTED_LINE_ITEMS) {
1152 return null;
1153 }
1154
1155 $lineItems[] = [
1156 'price_data' => [
1157 'currency' => $stripeCurrency,
1158 'product_data' => [
1159 'name' => $name,
1160 ],
1161 'unit_amount' => $unitAmount,
1162 ],
1163 'quantity' => $quantity,
1164 ];
1165
1166 // intdiv floors, so any per-unit remainder is left for the
1167 // reconciliation line below rather than silently inflating the charge.
1168 $sum += $unitAmount * $quantity;
1169 }
1170
1171 if (!$lineItems) {
1172 return null;
1173 }
1174
1175 $shipping = $this->toStripeWireAmount($order->shipping_total, $isZeroDecimal);
1176 if ($shipping > 0) {
1177 $lineItems[] = $this->hostedFlatLineItem(__('Shipping', 'fluent-cart'), $stripeCurrency, $shipping);
1178 $sum += $shipping;
1179 }
1180
1181 // Only tax the buyer pays ON TOP of item prices belongs here — inclusive
1182 // tax is already inside line_total and adding it would double-charge.
1183 $tax = $this->toStripeWireAmount($this->additiveTaxTotal($order), $isZeroDecimal);
1184 if ($tax > 0) {
1185 $lineItems[] = $this->hostedFlatLineItem(__('Tax', 'fluent-cart'), $stripeCurrency, $tax);
1186 $sum += $tax;
1187 }
1188
1189 if ($sum > $chargeAmount) {
1190 return null; // Cannot subtract without a Coupon object; aggregate instead.
1191 }
1192
1193 if ($sum < $chargeAmount) {
1194 $lineItems[] = $this->hostedFlatLineItem(
1195 __('Adjustment', 'fluent-cart'),
1196 $stripeCurrency,
1197 $chargeAmount - $sum
1198 );
1199 $sum = $chargeAmount;
1200 }
1201
1202 if ($sum !== $chargeAmount || count($lineItems) > self::MAX_HOSTED_LINE_ITEMS) {
1203 return null;
1204 }
1205
1206 return $lineItems;
1207 }
1208
1209 /**
1210 * The historical single-line body: one item worth the whole charge. Always a
1211 * correct amount, and the fallback for every path the breakdown declines.
1212 *
1213 * @param \FluentCart\App\Models\Order $order
1214 * @param string $currency
1215 * @param int $chargeAmount
1216 * @return array
1217 */
1218 private function aggregateHostedLineItems($order, $currency, $chargeAmount)
1219 {
1220 $storeName = (new \FluentCart\Api\StoreSettings())->get('store_name');
1221
1222 return [
1223 [
1224 'price_data' => [
1225 'currency' => strtolower($currency),
1226 'product_data' => [
1227 'name' => $storeName . ' - Order #' . $order->uuid,
1228 'description' => __('Order total including all items, shipping (If any), and taxes (If any)', 'fluent-cart'),
1229 ],
1230 'unit_amount' => $chargeAmount,
1231 ],
1232 'quantity' => 1,
1233 ]
1234 ];
1235 }
1236
1237 /**
1238 * @param string $name
1239 * @param string $stripeCurrency
1240 * @param int $amount
1241 * @return array
1242 */
1243 private function hostedFlatLineItem($name, $stripeCurrency, $amount)
1244 {
1245 return [
1246 'price_data' => [
1247 'currency' => $stripeCurrency,
1248 'product_data' => [
1249 'name' => $name,
1250 ],
1251 'unit_amount' => $amount,
1252 ],
1253 'quantity' => 1,
1254 ];
1255 }
1256
1257 /**
1258 * @param \FluentCart\App\Models\OrderItem $item
1259 * @return string
1260 */
1261 private function hostedLineItemName($item)
1262 {
1263 $name = trim($item->post_title . ' ' . $item->title);
1264
1265 if ($name === '') {
1266 return '';
1267 }
1268
1269 if (function_exists('mb_substr') && mb_strlen($name) > 250) {
1270 return mb_substr($name, 0, 247) . '...';
1271 }
1272
1273 if (strlen($name) > 250) {
1274 return substr($name, 0, 247) . '...';
1275 }
1276
1277 return $name;
1278 }
1279
1280 /**
1281 * Storage cents to the units Stripe is charged in. Every part of the
1282 * breakdown is converted individually and the caller reconciles the SUM
1283 * against the converted charge total — converting after summing would
1284 * disagree with Stripe, which only ever sees the per-item figures.
1285 *
1286 * @param mixed $cents
1287 * @param bool $isZeroDecimal
1288 * @return int
1289 */
1290 private function toStripeWireAmount($cents, $isZeroDecimal)
1291 {
1292 $cents = Helper::roundCent($cents);
1293
1294 return $isZeroDecimal ? intdiv($cents, 100) : $cents;
1295 }
1296
1297 /**
1298 * Tax the buyer pays on top of item prices, in storage cents.
1299 *
1300 * Mirrors the PayPal purchase-unit breakdown (PayPalGateway/Processor.php):
1301 * behaviour 1 is fully exclusive, 3 is mixed (only the exclusive portion is
1302 * additive, with shipping and fee tax additive only when the store itself is
1303 * exclusive), anything else is inclusive and contributes nothing.
1304 *
1305 * @param \FluentCart\App\Models\Order $order
1306 * @return int
1307 */
1308 private function additiveTaxTotal($order)
1309 {
1310 $taxBehavior = (int) $order->tax_behavior;
1311 $exclusiveTaxTotal = (int) $order->getMeta('exclusive_tax_total');
1312 $storeTaxBehavior = (int) $order->getMeta('store_tax_behavior');
1313 $feeTax = (int) $order->getMeta('fee_tax');
1314
1315 // Fallback: if meta missing (old order), use tax_behavior as store_tax_behavior
1316 if (empty($storeTaxBehavior) && $taxBehavior > 0) {
1317 $storeTaxBehavior = $taxBehavior;
1318 }
1319
1320 if ($taxBehavior === 1) {
1321 return Helper::roundCent($order->tax_total) + Helper::roundCent($order->shipping_tax);
1322 }
1323
1324 if ($taxBehavior === 3) {
1325 $taxTotal = $exclusiveTaxTotal;
1326
1327 if ($storeTaxBehavior === 1) {
1328 $taxTotal += Helper::roundCent($order->shipping_tax);
1329 $taxTotal += $feeTax;
1330 }
1331
1332 return $taxTotal;
1333 }
1334
1335 return 0;
1336 }
1337
1338 /**
1339 * True only for a structural rejection of the request body (Stripe
1340 * `invalid_request_error`). A transport failure is deliberately excluded: the
1341 * session may have been created, and retrying under a different idempotency
1342 * key would give up the duplicate protection that key exists for.
1343 *
1344 * @param mixed $response
1345 * @return bool
1346 */
1347 private function isStripeValidationError($response)
1348 {
1349 if (!is_wp_error($response) || $response->get_error_code() !== 'api_error') {
1350 return false;
1351 }
1352
1353 $body = $response->get_error_data();
1354
1355 return is_array($body) && Arr::get($body, 'error.type') === 'invalid_request_error';
1356 }
1357
1358
1359 /**
1360 * The one-time part of a hosted subscription session, itemised.
1361 *
1362 * `$initialAmount` is a bundle of up to four unrelated things — a merchant
1363 * setup fee, one-time cart items, order fees, and a synthetic first-cycle
1364 * delta the plan price cannot carry — so a single line can only ever be
1365 * labelled correctly for one of them. Everything the order itemises gets its
1366 * own line; whatever is left over (tax, the delta) becomes one remainder line
1367 * named for the case that produced it.
1368 *
1369 * Returns null when the breakdown cannot be reconciled to `$initialAmount`,
1370 * which sends the caller to the aggregate single line.
1371 *
1372 * @param PaymentInstance $paymentInstance
1373 * @param string $stripeCurrency
1374 * @param bool $isZeroDecimal
1375 * @param int $initialAmount Wire amount, already converted.
1376 * @param int $existingCount Lines already in the session body.
1377 * @return array|null
1378 */
1379 private function buildHostedSubscriptionInitialLineItems(PaymentInstance $paymentInstance, $stripeCurrency, $isZeroDecimal, $initialAmount, $existingCount)
1380 {
1381 $order = $paymentInstance->order;
1382
1383 $lineItems = [];
1384 $sum = 0;
1385
1386 // Already materialised by getExtraAddonAmount() before this runs, so reuse
1387 // the relation rather than querying again. Deterministic order: the
1388 // idempotency fingerprint hashes line_items.
1389 $orderItems = $order->order_items->sortBy('id')->values();
1390
1391 // Decline before building anything the cap would discard.
1392 if ($existingCount + $orderItems->count() > self::MAX_HOSTED_LINE_ITEMS) {
1393 return null;
1394 }
1395
1396 foreach ($orderItems as $item) {
1397 if ($item->payment_type === 'subscription') {
1398 continue; // Carried by the recurring price.
1399 }
1400
1401 $lineTotal = $this->toStripeWireAmount($item->line_total, $isZeroDecimal);
1402 if ($lineTotal <= 0) {
1403 continue;
1404 }
1405
1406 $quantity = (int)$item->quantity;
1407 if ($quantity < 1) {
1408 $quantity = 1;
1409 }
1410
1411 $unitAmount = intdiv($lineTotal, $quantity);
1412 if ($unitAmount <= 0) {
1413 continue;
1414 }
1415
1416 // A signup-fee row already carries the merchant's own label in
1417 // `title` (other_info.signup_fee_name), a fee row the fee label.
1418 $name = $this->hostedLineItemName($item);
1419
1420 if ($name === '') {
1421 return null;
1422 }
1423
1424 if ($existingCount + count($lineItems) >= self::MAX_HOSTED_LINE_ITEMS) {
1425 return null;
1426 }
1427
1428 $lineItems[] = [
1429 'price_data' => [
1430 'currency' => $stripeCurrency,
1431 'product_data' => [
1432 'name' => $name,
1433 ],
1434 'unit_amount' => $unitAmount,
1435 ],
1436 'quantity' => $quantity,
1437 ];
1438
1439 $sum += $unitAmount * $quantity;
1440 }
1441
1442 if ($sum > $initialAmount) {
1443 return null; // Cannot subtract without a Coupon object.
1444 }
1445
1446 if ($sum < $initialAmount) {
1447 $remainder = $initialAmount - $sum;
1448
1449 // No itemised part at all means the whole amount IS the first-cycle
1450 // delta, and it gets the case name rather than a tax label.
1451 $name = $lineItems
1452 ? __('Taxes & adjustments', 'fluent-cart')
1453 : $this->hostedSubscriptionInitialName($order, $paymentInstance->subscription);
1454
1455 if ($existingCount + count($lineItems) >= self::MAX_HOSTED_LINE_ITEMS) {
1456 return null;
1457 }
1458
1459 $lineItems[] = $this->hostedFlatLineItem($name, $stripeCurrency, $remainder);
1460 $sum += $remainder;
1461 }
1462
1463 if (!$lineItems || $sum !== $initialAmount) {
1464 return null;
1465 }
1466
1467 return $lineItems;
1468 }
1469
1470 /**
1471 * Label for a one-time amount that the order does not itemise.
1472 *
1473 * A configured setup fee owns the label when one exists. Otherwise the amount
1474 * is a first-cycle delta computed in CheckoutProcessor::convertToSubscriptionFormat():
1475 * with a simulated trial the recurring line charges nothing now, so this line
1476 * is the whole first payment; without one it is only the excess over recurring.
1477 *
1478 * @param \FluentCart\App\Models\Order $order
1479 * @param \FluentCart\App\Models\Subscription|null $subscriptionModel
1480 * @return string
1481 */
1482 private function hostedSubscriptionInitialName($order, $subscriptionModel)
1483 {
1484 $signupFeeItem = $order->order_items->first(function ($item) {
1485 return $item->payment_type === 'signup_fee';
1486 });
1487
1488 if ($signupFeeItem) {
1489 $name = $this->hostedLineItemName($signupFeeItem);
1490
1491 if ($name !== '') {
1492 return $name;
1493 }
1494 }
1495
1496 $simulatedTrial = $subscriptionModel
1497 && Arr::get($subscriptionModel->config, 'is_trial_days_simulated', 'no') === 'yes';
1498
1499 return $simulatedTrial
1500 ? __('First payment', 'fluent-cart')
1501 : __('First payment adjustment', 'fluent-cart');
1502 }
1503
1504 private function handleHostedSubscriptionCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
1505 {
1506 $order = $paymentInstance->order;
1507 $transaction = $paymentInstance->transaction;
1508 $subscriptionModel = $paymentInstance->subscription;
1509 $fcCustomer = $order->customer;
1510
1511 if (!$subscriptionModel) {
1512 return new \WP_Error('no_subscription', __('No subscription found.', 'fluent-cart'));
1513 }
1514
1515 // No request body: a hosted Checkout Session mints its own subscription,
1516 // so nothing is reusable here.
1517 $guardError = $this->guardExistingRemoteSubscription($subscriptionModel, $order);
1518 if (is_wp_error($guardError)) {
1519 return $guardError;
1520 }
1521
1522 $transactionCurrency = $transaction->currency;
1523 $orderType = $order->type;
1524
1525 // Create or get Stripe customer
1526 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($fcCustomer);
1527 if (is_wp_error($stripeCustomer)) {
1528 return $stripeCustomer;
1529 }
1530
1531 // Get or create Stripe price/plan
1532 if ($orderType == 'renewal') {
1533 $stripePlan = Plan::getStripePricing([
1534 'product_id' => $subscriptionModel->product_id,
1535 'variation_id' => $subscriptionModel->variation_id,
1536 'billing_interval' => $subscriptionModel->billing_interval,
1537 'recurring_total' => $subscriptionModel->getCurrentRenewalAmount(),
1538 'currency' => $order->currency,
1539 'trial_days' => $subscriptionModel->getReactivationTrialDays(),
1540 'interval_count' => 1,
1541 'order_id' => $subscriptionModel->parent_order_id,
1542 ]);
1543 } else {
1544 $stripePlan = Plan::getStripePricing([
1545 'product_id' => $subscriptionModel->product_id,
1546 'variation_id' => $subscriptionModel->variation_id,
1547 'billing_interval' => $subscriptionModel->billing_interval,
1548 'recurring_total' => $subscriptionModel->recurring_total,
1549 'currency' => $order->currency,
1550 'trial_days' => (int)$subscriptionModel->trial_days,
1551 'interval_count' => 1,
1552 'order_id' => $subscriptionModel->parent_order_id,
1553 ]);
1554 }
1555
1556 if (is_wp_error($stripePlan)) {
1557 return $stripePlan;
1558 }
1559
1560
1561 $feeTotal = $orderType !== 'renewal' ? (int)$paymentInstance->order->fee_total : 0;
1562 $initialAmount = (int)$subscriptionModel->signup_fee + $paymentInstance->getExtraAddonAmount() + $feeTotal;
1563
1564 if ($orderType == 'renewal') {
1565 $initialAmount = 0;
1566 }
1567
1568 $recurringTotal = (int)$subscriptionModel->recurring_total;
1569 $isZeroDecimal = $transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency);
1570 if ($isZeroDecimal) {
1571 $initialAmount = (int)($initialAmount / 100);
1572 $recurringTotal = (int)($recurringTotal / 100);
1573 }
1574
1575 $lineItems = [
1576 [
1577 'price' => $stripePlan['id'],
1578 'quantity' => $subscriptionModel->quantity ?: 1,
1579 ]
1580 ];
1581
1582 $subscriptionData = [
1583 'metadata' => [
1584 'fct_ref_id' => $order->uuid,
1585 'email' => $fcCustomer->email,
1586 'name' => $order->full_name,
1587 'order_reference' => 'fct_order_id_' . $order->id,
1588 'subscription_item' => $subscriptionModel->item_name,
1589 ],
1590 ];
1591
1592 // Handle trial period if set in plan (same as onsite lines 94-96)
1593 if (!empty($stripePlan['trial_period_days'])) {
1594 $subscriptionData['trial_period_days'] = $stripePlan['trial_period_days'];
1595 }
1596
1597 // can add billing cycle anchor config here, if we allow billing anchor in fluent-cart subscription
1598
1599 if ($initialAmount > 0) {
1600 $stripeCurrency = strtolower($order->currency);
1601
1602 $initialItems = $this->buildHostedSubscriptionInitialLineItems(
1603 $paymentInstance,
1604 $stripeCurrency,
1605 $isZeroDecimal,
1606 (int)$initialAmount,
1607 count($lineItems)
1608 );
1609
1610 if ($initialItems === null) {
1611 $initialItems = [
1612 $this->hostedFlatLineItem(
1613 $this->hostedSubscriptionInitialName($order, $subscriptionModel),
1614 $stripeCurrency,
1615 (int)$initialAmount
1616 )
1617 ];
1618 }
1619
1620 $lineItems = array_merge($lineItems, $initialItems);
1621 }
1622
1623 $sessionData = [
1624 'customer' => $stripeCustomer['id'],
1625 'client_reference_id' => $order->uuid,
1626 'line_items' => $lineItems,
1627 'mode' => 'subscription',
1628 'consent_collection' => ['payment_method_reuse_agreement' => ['position' => 'hidden']],
1629 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
1630 'cancel_url' => StripeHelper::getCancelUrl(),
1631 'subscription_data' => $subscriptionData,
1632 'saved_payment_method_options' => [
1633 'payment_method_save' => 'enabled'
1634 ],
1635 'metadata' => [
1636 'fct_ref_id' => $order->uuid,
1637 'subscription_item' => $subscriptionModel->item_name,
1638 'transaction_hash' => $transaction->uuid,
1639 'order_reference' => 'fct_order_id_' . $order->id,
1640 ],
1641 ];
1642
1643 $submitType = (new StripeSettingsBase())->getSubmitType();
1644 if ($submitType && in_array($submitType, ['donate', 'subscribe', 'auto'], true)) {
1645 $sessionData['submit_type'] = $submitType;
1646 }
1647
1648 $sessionData = apply_filters('fluent_cart/payments/stripe_subscription_checkout_session_args', $sessionData, [
1649 'order' => $order,
1650 'transaction' => $transaction,
1651 'subscription' => $subscriptionModel
1652 ]);
1653
1654 // Same duplicate-subscription defense as the onsite path, applied to the hosted
1655 // Checkout Session. Metadata is excluded so a volatile metadata filter cannot
1656 // change the key on a genuine duplicate and reopen the double-charge window.
1657 $idempotencyFingerprint = [
1658 'customer' => Arr::get($sessionData, 'customer'),
1659 'line_items' => Arr::get($sessionData, 'line_items'),
1660 'mode' => Arr::get($sessionData, 'mode'),
1661 'subscription_data' => Arr::get($sessionData, 'subscription_data'),
1662 // See the onsite path: rolls the key after a guard cancel, read from
1663 // the persisted marker so retries recompute the same key.
1664 'replaces' => (string)Arr::get(
1665 (array)$subscriptionModel->config,
1666 'stripe_replaced_vendor_sub_id',
1667 ''
1668 ),
1669 ];
1670 $idempotencySeed = $paymentInstance->getIdempotencySeed();
1671 $idempotencyKey = $idempotencySeed
1672 ? 'fct_stripe_sub_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
1673 : null;
1674
1675 $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
1676 'Idempotency-Key' => $idempotencyKey
1677 ]);
1678
1679 if (is_wp_error($session)) {
1680 return $session;
1681 }
1682
1683 $subscriptionModel->update([
1684 'vendor_customer_id' => $stripeCustomer['id']
1685 ]);
1686
1687 $transaction->update([
1688 'vendor_charge_id' => Arr::get($session, 'payment_intent', Arr::get($session, 'id')),
1689 'meta' => array_merge($transaction->meta ?? [], [
1690 'session_id' => $session['id']
1691 ])
1692 ]);
1693
1694 return [
1695 'status' => 'success',
1696 'nextAction' => 'stripe',
1697 'actionName' => 'redirect',
1698 'message' => __('Redirecting to Stripe checkout...', 'fluent-cart'),
1699 'response' => $session,
1700 'payment_args' => array_merge($paymentArgs, [
1701 'checkout_url' => $session['url'],
1702 'session_id' => $session['id']
1703 ])
1704 ];
1705 }
1706
1707 }
1708