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 +929 -91 1.6.0 → 1.6.6 View file →
@@ -11,9 +11,48 @@
11 11 use FluentCart\Framework\Support\Arr;
12 12
13 13 class Processor
14 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;
15 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 +
16 55 public function handleSubscription(PaymentInstance $paymentInstance, $paymentArgs)
17 56 {
18 57 $stripeSettings = new StripeSettingsBase();
19 58 $checkoutMode = $stripeSettings->get('checkout_mode') ?? 'onsite';
@@ -33,12 +72,8 @@
33 72 if (!$subscriptionModel) {
34 73 return new \WP_Error('no_subscription', __('No subscription found.', 'fluent-cart'));
35 74 }
36 75
37 - if ($guardError = $this->guardExistingRemoteSubscription($subscriptionModel)) {
38 - return $guardError;
39 - }
40 -
41 76 $stripeCustomer = StripeHelper::createOrGetStripeCustomer($paymentInstance->order->customer);
42 77
43 78 if (is_wp_error($stripeCustomer)) {
44 79 return $stripeCustomer;
@@ -157,13 +192,24 @@
157 192 // gets a fresh key instead of a same-key/changed-parameters 400 (the abandoned
158 193 // incomplete subscription auto-expires). Params, not transaction->total: a
159 194 // recurring coupon can change the plan while the first charge stays $0.
160 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 +
161 202 $idempotencyFingerprint = [
162 203 'customer' => Arr::get($stripeSubscriptionData, 'customer'),
163 204 'items' => Arr::get($stripeSubscriptionData, 'items'),
164 205 'add_invoice_items' => Arr::get($stripeSubscriptionData, 'add_invoice_items'),
165 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 + ),
166 212 ];
167 213 $idempotencySeed = $paymentInstance->getIdempotencySeed();
168 214 $idempotencyKey = $idempotencySeed
169 215 ? 'fct_stripe_sub_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
@@ -168,17 +214,25 @@
168 214 $idempotencyKey = $idempotencySeed
169 215 ? 'fct_stripe_sub_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
170 216 : null;
171 217
172 - $stripeSubscription = (new API())->createStripeObject('subscriptions', $stripeSubscriptionData, 'current', [
173 - 'Idempotency-Key' => $idempotencyKey
174 - ]);
218 + if ($existingRemoteSubscription) {
219 + $stripeSubscription = $existingRemoteSubscription;
220 + } else {
221 + $stripeSubscription = (new API())->createStripeObject('subscriptions', $stripeSubscriptionData, 'current', [
222 + 'Idempotency-Key' => $idempotencyKey
223 + ]);
224 + }
175 225
176 226 if (is_wp_error($stripeSubscription)) {
177 227 return $stripeSubscription;
178 228 }
179 229
230 + // A guard-reused sub carries an expanded payment_intent object here.
180 231 $vendorChargeId = Arr::get($stripeSubscription, 'latest_invoice.payment_intent');
232 + if (is_array($vendorChargeId)) {
233 + $vendorChargeId = Arr::get($vendorChargeId, 'id');
234 + }
181 235 if (!$vendorChargeId) {
182 236 $vendorChargeId = Arr::get($stripeSubscription, 'pending_setup_intent.id');
183 237 }
184 238
@@ -192,15 +246,14 @@
192 246 'vendor_subscription_id' => $vendorSubscriptionId,
193 247 'vendor_customer_id' => $stripeSubscription['customer']
194 248 ];
195 249
250 + $subscriptionModel->update($subscriptionUpdateFields);
251 +
196 252 if ($orderType == 'renewal' && Arr::get($stripePlan, 'trial_period_days', 0) > 0) {
197 - $config = $subscriptionModel->config ?: [];
198 - $subscriptionUpdateFields['config'] = array_merge($config, ['is_trial_days_simulated' => 'yes']);
253 + $subscriptionModel->mergeConfig(['is_trial_days_simulated' => 'yes']);
199 254 }
200 255
201 - $subscriptionModel->update($subscriptionUpdateFields);
202 -
203 256 if ($stripeSubscription['pending_setup_intent'] != null) {
204 257 $paymentArgs['vendor_subscription_info'] = [
205 258 'type' => 'setup',
206 259 'clientSecret' => Arr::get($stripeSubscription, 'pending_setup_intent.client_secret'),
@@ -239,20 +292,52 @@
239 292 * A retryable checkout can arrive with a live Stripe subscription already
240 293 * attached — the previous create succeeded but its confirm/webhook never
241 294 * landed, and a changed cart mints a fresh idempotency key, so the key alone
242 295 * cannot stop a second create. A second create bills the customer on a
243 - * subscription the store cannot see or cancel. Billing-active remote: block
244 - * the create and re-sync local state from Stripe. Unconfirmed incomplete
245 - * remote: cancel it so the fresh create is the only confirmable one.
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
246 324 */
247 - private function guardExistingRemoteSubscription($subscriptionModel)
325 + private function guardExistingRemoteSubscription($subscriptionModel, $order, $requestData = [])
248 326 {
249 327 $existingVendorSubId = $subscriptionModel->vendor_subscription_id;
250 - if (!$existingVendorSubId) {
328 + if (!$existingVendorSubId || strpos($existingVendorSubId, 'sub_') !== 0) {
251 329 return null;
252 330 }
253 331
254 - $remoteSub = (new API())->getStripeObject('subscriptions/' . $existingVendorSubId, [], 'current');
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 +
255 340 if (is_wp_error($remoteSub)) {
256 341 return null;
257 342 }
258 343
@@ -257,20 +342,258 @@
257 342 }
258 343
259 344 $remoteStatus = Arr::get($remoteSub, 'status');
260 345
261 - if (in_array($remoteStatus, ['active', 'trialing', 'past_due', 'unpaid'], true)) {
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 +
262 360 (new StripeSubscriptions())->reSyncSubscriptionFromRemote($subscriptionModel);
263 361 return new \WP_Error(
264 362 'stripe_subscription_already_active',
265 - __('Your subscription payment has already been processed. Please refresh this page to see your order status instead of paying again.', 'fluent-cart')
363 + __('Subscription is already active. Please refresh this page to see the status instead of trying again.', 'fluent-cart')
266 364 );
267 365 }
268 366
269 - if ('incomplete' === $remoteStatus) {
270 - $cancelResponse = (new API())->deleteStripeObject('subscriptions/' . $existingVendorSubId, [], 'current');
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');
271 582 if (is_wp_error($cancelResponse)) {
272 - fluent_cart_error_log('Stripe stale incomplete subscription cancel failed. Subscription ID: ' . $subscriptionModel->id, $cancelResponse->get_error_message());
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 + );
273 596 }
274 597 }
275 598
276 599 return null;
@@ -398,9 +721,9 @@
398 721 'customer' => $stripeCustomer['id'],
399 722 'client_reference_id' => $order->uuid,
400 723 'mode' => 'setup',
401 724 'currency' => strtolower($transactionCurrency),
402 - 'success_url' => Arr::get($paymentArgs, 'success_url') . '&fct_stripe_hosted=1&trx_hash=' . $transaction->uuid,
725 + 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
403 726 'cancel_url' => StripeHelper::getCancelUrl(),
404 727 'metadata' => [
405 728 'fct_ref_id' => $order->uuid,
406 729 'transaction_hash' => $transaction->uuid,
@@ -520,35 +843,49 @@
520 843 'order' => $order,
521 844 'transaction' => $transaction
522 845 ]);
523 846
524 - // Same duplicate-charge defense for one-time onsite payments. Customer is in
525 - // the fingerprint because a guest editing their email between attempts maps to
526 - // a different Stripe customer — same key there would 400 for the key's 24h
527 - // lifetime. Built AFTER the intent-args filter so filtered amounts are what
528 - // get fingerprinted.
529 - $idempotencyFingerprint = [
530 - 'amount' => Arr::get($intentData, 'amount'),
531 - 'currency' => Arr::get($intentData, 'currency'),
532 - 'customer' => Arr::get($intentData, 'customer'),
533 - ];
534 - $idempotencySeed = $paymentInstance->getIdempotencySeed();
535 - $idempotencyKey = $idempotencySeed
536 - ? 'fct_stripe_pi_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
537 - : null;
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 + }
538 854
539 - $intent = (new API())->createStripeObject('payment_intents', $intentData, 'current', [
540 - 'Idempotency-Key' => $idempotencyKey
541 - ]);
542 -
543 - if (is_wp_error($intent)) {
855 + if (!empty($intent['fct_redirect'])) {
544 856 return $intent;
545 857 }
546 858
547 - $transaction->update([
548 - 'vendor_charge_id' => $intent['id']
549 - ]);
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;
550 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 +
551 888 $customerData = [
552 889 'name' => $fcCustomer->first_name . ' ' . $fcCustomer->last_name,
553 890 'email' => $fcCustomer->email,
554 891 'address_1' => $billingAddress->address_1,
@@ -590,31 +927,25 @@
590 927 if (is_wp_error($stripeCustomer)) {
591 928 return $stripeCustomer;
592 929 }
593 930
594 - // Use a single line item with the total amount to avoid complexity
595 - // This is simpler and prevents any calculation mismatches
596 - $storeName = (new \FluentCart\Api\StoreSettings())->get('store_name');
597 - $lineItems = [
598 - [
599 - 'price_data' => [
600 - 'currency' => strtolower($transactionCurrency),
601 - 'product_data' => [
602 - 'name' => $storeName . ' - Order #' . $order->uuid,
603 - 'description' => sprintf(__('Order total including all items, shipping (If any), and taxes (If any)', 'fluent-cart')),
604 - ],
605 - 'unit_amount' => $chargeAmount,
606 - ],
607 - 'quantity' => 1,
608 - ]
609 - ];
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;
610 937
938 + if (!$usedBreakdown) {
939 + $lineItems = $this->aggregateHostedLineItems($order, $transactionCurrency, $chargeAmount);
940 + }
941 +
611 942 $sessionData = [
612 943 'customer' => $stripeCustomer['id'],
613 944 'client_reference_id' => $order->uuid,
614 945 'line_items' => $lineItems,
615 946 'mode' => 'payment',
616 - 'success_url' => Arr::get($paymentArgs, 'success_url') . '&fct_stripe_hosted=1&trx_hash=' . $transaction->uuid,
947 + 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
617 948 'cancel_url' => StripeHelper::getCancelUrl(),
618 949 'metadata' => [
619 950 'fct_ref_id' => $order->uuid,
620 951 'transaction_hash' => $transaction->uuid,
@@ -630,8 +961,13 @@
630 961 'setup_future_usage' => $paymentArgs['setup_future_usage'],
631 962 ];
632 963 }
633 964
965 + $submitType = (new StripeSettingsBase())->getSubmitType();
966 + if ($submitType && in_array($submitType, ['auto', 'book', 'donate', 'pay'], true)) {
967 + $sessionData['submit_type'] = $submitType;
968 + }
969 +
634 970 $itemCount = 1;
635 971 foreach($order->order_items as $item) {
636 972 $sessionData['metadata']['item ' . $itemCount] = 'Name: ' . $item->title . ', ' . 'Qty: ' . $item->quantity . ', Price: ' . Helper::toDecimal($item->line_total, false, null, true, true, false);
637 973 if (count($sessionData['metadata']) > 49) {
@@ -640,30 +976,61 @@
640 976
641 977 $itemCount++;
642 978 }
643 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 +
644 984 $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $sessionData, [
645 985 'order' => $order,
646 986 'transaction' => $transaction
647 987 ]);
648 988
649 - // Same duplicate-charge defense as every other Stripe create path: a pure
650 - // duplicate replays the key and gets the original session back; an edited-cart
651 - // resubmit gets a fresh key instead of a same-key/changed-parameters 400.
652 - $idempotencyFingerprint = [
653 - 'customer' => Arr::get($sessionData, 'customer'),
654 - 'line_items' => Arr::get($sessionData, 'line_items'),
655 - 'mode' => Arr::get($sessionData, 'mode'),
656 - ];
657 989 $idempotencySeed = $paymentInstance->getIdempotencySeed();
658 - $idempotencyKey = $idempotencySeed
659 - ? 'fct_stripe_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
660 - : null;
990 + $idempotencyKey = $this->hostedSessionIdempotencyKey($idempotencySeed, $sessionData);
661 991
662 992 $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
663 993 'Idempotency-Key' => $idempotencyKey
664 994 ]);
665 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 +
666 1033 if (is_wp_error($session)) {
667 1034 return $session;
668 1035 }
669 1036
@@ -686,8 +1053,455 @@
686 1053 ];
687 1054 }
688 1055
689 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 +
690 1504 private function handleHostedSubscriptionCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
691 1505 {
692 1506 $order = $paymentInstance->order;
693 1507 $transaction = $paymentInstance->transaction;
@@ -697,9 +1511,12 @@
697 1511 if (!$subscriptionModel) {
698 1512 return new \WP_Error('no_subscription', __('No subscription found.', 'fluent-cart'));
699 1513 }
700 1514
701 - if ($guardError = $this->guardExistingRemoteSubscription($subscriptionModel)) {
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)) {
702 1519 return $guardError;
703 1520 }
704 1521
705 1522 $transactionCurrency = $transaction->currency;
@@ -748,9 +1565,10 @@
748 1565 $initialAmount = 0;
749 1566 }
750 1567
751 1568 $recurringTotal = (int)$subscriptionModel->recurring_total;
752 - if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
1569 + $isZeroDecimal = $transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency);
1570 + if ($isZeroDecimal) {
753 1571 $initialAmount = (int)($initialAmount / 100);
754 1572 $recurringTotal = (int)($recurringTotal / 100);
755 1573 }
756 1574
@@ -775,27 +1593,32 @@
775 1593 if (!empty($stripePlan['trial_period_days'])) {
776 1594 $subscriptionData['trial_period_days'] = $stripePlan['trial_period_days'];
777 1595 }
778 1596
1597 + // can add billing cycle anchor config here, if we allow billing anchor in fluent-cart subscription
1598 +
779 1599 if ($initialAmount > 0) {
780 - $addonPrice = Plan::getOneTimeAddonPrice([
781 - 'product_id' => $subscriptionModel->product_id,
782 - 'currency' => $order->currency,
783 - 'amount' => (int)$initialAmount,
784 - 'name' => __('Signup fee / initial payment', 'fluent-cart'),
785 - 'variation_id' => $subscriptionModel->variation_id,
786 - 'order_id' => $subscriptionModel->parent_order_id,
1600 + $stripeCurrency = strtolower($order->currency);
787 1601
788 - ]);
1602 + $initialItems = $this->buildHostedSubscriptionInitialLineItems(
1603 + $paymentInstance,
1604 + $stripeCurrency,
1605 + $isZeroDecimal,
1606 + (int)$initialAmount,
1607 + count($lineItems)
1608 + );
789 1609
790 - if (is_wp_error($addonPrice)) {
791 - return $addonPrice;
792 - };
1610 + if ($initialItems === null) {
1611 + $initialItems = [
1612 + $this->hostedFlatLineItem(
1613 + $this->hostedSubscriptionInitialName($order, $subscriptionModel),
1614 + $stripeCurrency,
1615 + (int)$initialAmount
1616 + )
1617 + ];
1618 + }
793 1619
794 - $lineItems[] = [
795 - 'price' => $addonPrice['id'],
796 - 'quantity' => 1
797 - ];
1620 + $lineItems = array_merge($lineItems, $initialItems);
798 1621 }
799 1622
800 1623 $sessionData = [
801 1624 'customer' => $stripeCustomer['id'],
@@ -802,11 +1625,14 @@
802 1625 'client_reference_id' => $order->uuid,
803 1626 'line_items' => $lineItems,
804 1627 'mode' => 'subscription',
805 1628 'consent_collection' => ['payment_method_reuse_agreement' => ['position' => 'hidden']],
806 - 'success_url' => Arr::get($paymentArgs, 'success_url') . '&fct_stripe_hosted=1&trx_hash=' . $transaction->uuid,
1629 + 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
807 1630 'cancel_url' => StripeHelper::getCancelUrl(),
808 1631 'subscription_data' => $subscriptionData,
1632 + 'saved_payment_method_options' => [
1633 + 'payment_method_save' => 'enabled'
1634 + ],
809 1635 'metadata' => [
810 1636 'fct_ref_id' => $order->uuid,
811 1637 'subscription_item' => $subscriptionModel->item_name,
812 1638 'transaction_hash' => $transaction->uuid,
@@ -813,8 +1639,13 @@
813 1639 'order_reference' => 'fct_order_id_' . $order->id,
814 1640 ],
815 1641 ];
816 1642
1643 + $submitType = (new StripeSettingsBase())->getSubmitType();
1644 + if ($submitType && in_array($submitType, ['donate', 'subscribe', 'auto'], true)) {
1645 + $sessionData['submit_type'] = $submitType;
1646 + }
1647 +
817 1648 $sessionData = apply_filters('fluent_cart/payments/stripe_subscription_checkout_session_args', $sessionData, [
818 1649 'order' => $order,
819 1650 'transaction' => $transaction,
820 1651 'subscription' => $subscriptionModel
@@ -827,8 +1658,15 @@
827 1658 'customer' => Arr::get($sessionData, 'customer'),
828 1659 'line_items' => Arr::get($sessionData, 'line_items'),
829 1660 'mode' => Arr::get($sessionData, 'mode'),
830 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 + ),
831 1669 ];
832 1670 $idempotencySeed = $paymentInstance->getIdempotencySeed();
833 1671 $idempotencyKey = $idempotencySeed
834 1672 ? 'fct_stripe_sub_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))