PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
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
← All changes | app/Modules/PaymentMethods/StripeGateway/Processor.php +1154 -70 1.5.4 → 1.6.6 View file →
@@ -1,8 +1,9 @@
1 1 <?php
2 2
3 3 namespace FluentCart\App\Modules\PaymentMethods\StripeGateway;
4 4
5 +use FluentCart\App\App;
5 6 use FluentCart\App\Helpers\CurrenciesHelper;
6 7 use FluentCart\App\Models\Cart;
7 8 use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API;
8 9 use FluentCart\App\Services\Payments\PaymentInstance;
@@ -10,9 +11,48 @@
10 11 use FluentCart\Framework\Support\Arr;
11 12
12 13 class Processor
13 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;
14 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 +
15 55 public function handleSubscription(PaymentInstance $paymentInstance, $paymentArgs)
16 56 {
17 57 $stripeSettings = new StripeSettingsBase();
18 58 $checkoutMode = $stripeSettings->get('checkout_mode') ?? 'onsite';
@@ -152,13 +192,24 @@
152 192 // gets a fresh key instead of a same-key/changed-parameters 400 (the abandoned
153 193 // incomplete subscription auto-expires). Params, not transaction->total: a
154 194 // recurring coupon can change the plan while the first charge stays $0.
155 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 +
156 202 $idempotencyFingerprint = [
157 203 'customer' => Arr::get($stripeSubscriptionData, 'customer'),
158 204 'items' => Arr::get($stripeSubscriptionData, 'items'),
159 205 'add_invoice_items' => Arr::get($stripeSubscriptionData, 'add_invoice_items'),
160 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 + ),
161 212 ];
162 213 $idempotencySeed = $paymentInstance->getIdempotencySeed();
163 214 $idempotencyKey = $idempotencySeed
164 215 ? 'fct_stripe_sub_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
@@ -163,15 +214,25 @@
163 214 $idempotencyKey = $idempotencySeed
164 215 ? 'fct_stripe_sub_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
165 216 : null;
166 217
167 - $stripeSubscription = (new API())->createStripeObject('subscriptions', $stripeSubscriptionData, 'current', $idempotencyKey);
218 + if ($existingRemoteSubscription) {
219 + $stripeSubscription = $existingRemoteSubscription;
220 + } else {
221 + $stripeSubscription = (new API())->createStripeObject('subscriptions', $stripeSubscriptionData, 'current', [
222 + 'Idempotency-Key' => $idempotencyKey
223 + ]);
224 + }
168 225
169 226 if (is_wp_error($stripeSubscription)) {
170 227 return $stripeSubscription;
171 228 }
172 229
230 + // A guard-reused sub carries an expanded payment_intent object here.
173 231 $vendorChargeId = Arr::get($stripeSubscription, 'latest_invoice.payment_intent');
232 + if (is_array($vendorChargeId)) {
233 + $vendorChargeId = Arr::get($vendorChargeId, 'id');
234 + }
174 235 if (!$vendorChargeId) {
175 236 $vendorChargeId = Arr::get($stripeSubscription, 'pending_setup_intent.id');
176 237 }
177 238
@@ -180,13 +241,19 @@
180 241 }
181 242
182 243 $vendorSubscriptionId = Arr::get($stripeSubscription, 'id');
183 244
184 - $subscriptionModel->update([
245 + $subscriptionUpdateFields = [
185 246 'vendor_subscription_id' => $vendorSubscriptionId,
186 247 'vendor_customer_id' => $stripeSubscription['customer']
187 - ]);
248 + ];
188 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 +
189 256 if ($stripeSubscription['pending_setup_intent'] != null) {
190 257 $paymentArgs['vendor_subscription_info'] = [
191 258 'type' => 'setup',
192 259 'clientSecret' => Arr::get($stripeSubscription, 'pending_setup_intent.client_secret'),
@@ -220,14 +287,497 @@
220 287 'fc_customer' => $customerData
221 288 ];
222 289 }
223 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 + }
224 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 +
225 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 + /**
226 604 * Handle single payment for stripe (onsite or hosted)
227 605 *
228 606 * @return \WP_Error|array
229 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 +
230 780 public function handleSinglePayment(PaymentInstance $paymentInstance, $paymentArgs = [])
231 781 {
232 782 $stripeSettings = new StripeSettingsBase();
233 783 $checkoutMode = $stripeSettings->get('checkout_mode') ?? 'onsite';
@@ -293,33 +843,49 @@
293 843 'order' => $order,
294 844 'transaction' => $transaction
295 845 ]);
296 846
297 - // Same duplicate-charge defense for one-time onsite payments. Customer is in
298 - // the fingerprint because a guest editing their email between attempts maps to
299 - // a different Stripe customer — same key there would 400 for the key's 24h
300 - // lifetime. Built AFTER the intent-args filter so filtered amounts are what
301 - // get fingerprinted.
302 - $idempotencyFingerprint = [
303 - 'amount' => Arr::get($intentData, 'amount'),
304 - 'currency' => Arr::get($intentData, 'currency'),
305 - 'customer' => Arr::get($intentData, 'customer'),
306 - ];
307 - $idempotencySeed = $paymentInstance->getIdempotencySeed();
308 - $idempotencyKey = $idempotencySeed
309 - ? 'fct_stripe_pi_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
310 - : null;
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 + }
311 854
312 - $intent = (new API())->createStripeObject('payment_intents', $intentData, 'current', $idempotencyKey);
313 -
314 - if (is_wp_error($intent)) {
855 + if (!empty($intent['fct_redirect'])) {
315 856 return $intent;
316 857 }
317 858
318 - $transaction->update([
319 - 'vendor_charge_id' => $intent['id']
320 - ]);
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;
321 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 +
322 888 $customerData = [
323 889 'name' => $fcCustomer->first_name . ' ' . $fcCustomer->last_name,
324 890 'email' => $fcCustomer->email,
325 891 'address_1' => $billingAddress->address_1,
@@ -361,31 +927,25 @@
361 927 if (is_wp_error($stripeCustomer)) {
362 928 return $stripeCustomer;
363 929 }
364 930
365 - // Use a single line item with the total amount to avoid complexity
366 - // This is simpler and prevents any calculation mismatches
367 - $storeName = (new \FluentCart\Api\StoreSettings())->get('store_name');
368 - $lineItems = [
369 - [
370 - 'price_data' => [
371 - 'currency' => strtolower($transactionCurrency),
372 - 'product_data' => [
373 - 'name' => $storeName . ' - Order #' . $order->uuid,
374 - 'description' => sprintf(__('Order total including all items, shipping (If any), and taxes (If any)', 'fluent-cart')),
375 - ],
376 - 'unit_amount' => $chargeAmount,
377 - ],
378 - 'quantity' => 1,
379 - ]
380 - ];
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;
381 937
938 + if (!$usedBreakdown) {
939 + $lineItems = $this->aggregateHostedLineItems($order, $transactionCurrency, $chargeAmount);
940 + }
941 +
382 942 $sessionData = [
383 943 'customer' => $stripeCustomer['id'],
384 944 'client_reference_id' => $order->uuid,
385 945 'line_items' => $lineItems,
386 946 'mode' => 'payment',
387 - 'success_url' => Arr::get($paymentArgs, 'success_url') . '&fct_stripe_hosted=1&trx_hash=' . $transaction->uuid,
947 + 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
388 948 'cancel_url' => StripeHelper::getCancelUrl(),
389 949 'metadata' => [
390 950 'fct_ref_id' => $order->uuid,
391 951 'transaction_hash' => $transaction->uuid,
@@ -392,8 +952,22 @@
392 952 'order_reference' => 'fct_order_id_' . $order->id,
393 953 ],
394 954 ];
395 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 +
396 970 $itemCount = 1;
397 971 foreach($order->order_items as $item) {
398 972 $sessionData['metadata']['item ' . $itemCount] = 'Name: ' . $item->title . ', ' . 'Qty: ' . $item->quantity . ', Price: ' . Helper::toDecimal($item->line_total, false, null, true, true, false);
399 973 if (count($sessionData['metadata']) > 49) {
@@ -402,28 +976,61 @@
402 976
403 977 $itemCount++;
404 978 }
405 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 +
406 984 $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $sessionData, [
407 985 'order' => $order,
408 986 'transaction' => $transaction
409 987 ]);
410 988
411 - // Same duplicate-charge defense as every other Stripe create path: a pure
412 - // duplicate replays the key and gets the original session back; an edited-cart
413 - // resubmit gets a fresh key instead of a same-key/changed-parameters 400.
414 - $idempotencyFingerprint = [
415 - 'customer' => Arr::get($sessionData, 'customer'),
416 - 'line_items' => Arr::get($sessionData, 'line_items'),
417 - 'mode' => Arr::get($sessionData, 'mode'),
418 - ];
419 989 $idempotencySeed = $paymentInstance->getIdempotencySeed();
420 - $idempotencyKey = $idempotencySeed
421 - ? 'fct_stripe_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
422 - : null;
990 + $idempotencyKey = $this->hostedSessionIdempotencyKey($idempotencySeed, $sessionData);
423 991
424 - $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', $idempotencyKey);
992 + $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
993 + 'Idempotency-Key' => $idempotencyKey
994 + ]);
425 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 +
426 1033 if (is_wp_error($session)) {
427 1034 return $session;
428 1035 }
429 1036
@@ -446,8 +1053,455 @@
446 1053 ];
447 1054 }
448 1055
449 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 +
450 1504 private function handleHostedSubscriptionCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
451 1505 {
452 1506 $order = $paymentInstance->order;
453 1507 $transaction = $paymentInstance->transaction;
@@ -457,8 +1511,15 @@
457 1511 if (!$subscriptionModel) {
458 1512 return new \WP_Error('no_subscription', __('No subscription found.', 'fluent-cart'));
459 1513 }
460 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 +
461 1522 $transactionCurrency = $transaction->currency;
462 1523 $orderType = $order->type;
463 1524
464 1525 // Create or get Stripe customer
@@ -504,9 +1565,10 @@
504 1565 $initialAmount = 0;
505 1566 }
506 1567
507 1568 $recurringTotal = (int)$subscriptionModel->recurring_total;
508 - if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
1569 + $isZeroDecimal = $transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency);
1570 + if ($isZeroDecimal) {
509 1571 $initialAmount = (int)($initialAmount / 100);
510 1572 $recurringTotal = (int)($recurringTotal / 100);
511 1573 }
512 1574
@@ -531,27 +1593,32 @@
531 1593 if (!empty($stripePlan['trial_period_days'])) {
532 1594 $subscriptionData['trial_period_days'] = $stripePlan['trial_period_days'];
533 1595 }
534 1596
1597 + // can add billing cycle anchor config here, if we allow billing anchor in fluent-cart subscription
1598 +
535 1599 if ($initialAmount > 0) {
536 - $addonPrice = Plan::getOneTimeAddonPrice([
537 - 'product_id' => $subscriptionModel->product_id,
538 - 'currency' => $order->currency,
539 - 'amount' => (int)$initialAmount,
540 - 'name' => __('Signup fee / initial payment', 'fluent-cart'),
541 - 'variation_id' => $subscriptionModel->variation_id,
542 - 'order_id' => $subscriptionModel->parent_order_id,
1600 + $stripeCurrency = strtolower($order->currency);
543 1601
544 - ]);
1602 + $initialItems = $this->buildHostedSubscriptionInitialLineItems(
1603 + $paymentInstance,
1604 + $stripeCurrency,
1605 + $isZeroDecimal,
1606 + (int)$initialAmount,
1607 + count($lineItems)
1608 + );
545 1609
546 - if (is_wp_error($addonPrice)) {
547 - return $addonPrice;
548 - };
1610 + if ($initialItems === null) {
1611 + $initialItems = [
1612 + $this->hostedFlatLineItem(
1613 + $this->hostedSubscriptionInitialName($order, $subscriptionModel),
1614 + $stripeCurrency,
1615 + (int)$initialAmount
1616 + )
1617 + ];
1618 + }
549 1619
550 - $lineItems[] = [
551 - 'price' => $addonPrice['id'],
552 - 'quantity' => 1
553 - ];
1620 + $lineItems = array_merge($lineItems, $initialItems);
554 1621 }
555 1622
556 1623 $sessionData = [
557 1624 'customer' => $stripeCustomer['id'],
@@ -558,11 +1625,14 @@
558 1625 'client_reference_id' => $order->uuid,
559 1626 'line_items' => $lineItems,
560 1627 'mode' => 'subscription',
561 1628 'consent_collection' => ['payment_method_reuse_agreement' => ['position' => 'hidden']],
562 - 'success_url' => Arr::get($paymentArgs, 'success_url') . '&fct_stripe_hosted=1&trx_hash=' . $transaction->uuid,
1629 + 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
563 1630 'cancel_url' => StripeHelper::getCancelUrl(),
564 1631 'subscription_data' => $subscriptionData,
1632 + 'saved_payment_method_options' => [
1633 + 'payment_method_save' => 'enabled'
1634 + ],
565 1635 'metadata' => [
566 1636 'fct_ref_id' => $order->uuid,
567 1637 'subscription_item' => $subscriptionModel->item_name,
568 1638 'transaction_hash' => $transaction->uuid,
@@ -569,8 +1639,13 @@
569 1639 'order_reference' => 'fct_order_id_' . $order->id,
570 1640 ],
571 1641 ];
572 1642
1643 + $submitType = (new StripeSettingsBase())->getSubmitType();
1644 + if ($submitType && in_array($submitType, ['donate', 'subscribe', 'auto'], true)) {
1645 + $sessionData['submit_type'] = $submitType;
1646 + }
1647 +
573 1648 $sessionData = apply_filters('fluent_cart/payments/stripe_subscription_checkout_session_args', $sessionData, [
574 1649 'order' => $order,
575 1650 'transaction' => $transaction,
576 1651 'subscription' => $subscriptionModel
@@ -583,8 +1658,15 @@
583 1658 'customer' => Arr::get($sessionData, 'customer'),
584 1659 'line_items' => Arr::get($sessionData, 'line_items'),
585 1660 'mode' => Arr::get($sessionData, 'mode'),
586 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 + ),
587 1669 ];
588 1670 $idempotencySeed = $paymentInstance->getIdempotencySeed();
589 1671 $idempotencyKey = $idempotencySeed
590 1672 ? 'fct_stripe_sub_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
@@ -589,9 +1671,11 @@
589 1671 $idempotencyKey = $idempotencySeed
590 1672 ? 'fct_stripe_sub_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
591 1673 : null;
592 1674
593 - $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', $idempotencyKey);
1675 + $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
1676 + 'Idempotency-Key' => $idempotencyKey
1677 + ]);
594 1678
595 1679 if (is_wp_error($session)) {
596 1680 return $session;
597 1681 }