PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.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 +1206 -46 1.3.21 → 1.6.5 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';
@@ -101,9 +141,24 @@
101 141 ]),
102 142 ];
103 143
104 144 if (Arr::get($stripePlan, 'trial_period_days')) {
105 - $stripeSubscriptionData['trial_end'] = strtotime('+' . Arr::get($stripePlan, 'trial_period_days') . ' days');
145 + // Anchor trial_end to a STABLE point (the charge transaction's creation time,
146 + // which is preserved across re-submissions) instead of "now". A volatile
147 + // trial_end would make a retried create send a different body, and Stripe
148 + // rejects a reused Idempotency-Key whose parameters changed (400), bricking
149 + // the order for the key's 24h lifetime. Anchoring keeps retries byte-identical.
150 + $trialDays = (int) Arr::get($stripePlan, 'trial_period_days');
151 + $anchorTs = $paymentInstance->transaction && $paymentInstance->transaction->created_at
152 + ? strtotime($paymentInstance->transaction->created_at . ' UTC')
153 + : time();
154 + $trialEnd = strtotime('+' . $trialDays . ' days', $anchorTs);
155 + // Stripe requires trial_end in the future; only a stale late retry could fall
156 + // behind, and that order would already carry a fresh transaction/key anyway.
157 + if ($trialEnd <= time() + MINUTE_IN_SECONDS) {
158 + $trialEnd = strtotime('+' . $trialDays . ' days');
159 + }
160 + $stripeSubscriptionData['trial_end'] = $trialEnd;
106 161 }
107 162
108 163 // Maybe we have initial amount
109 164 if ($initialAmount) {
@@ -130,15 +185,54 @@
130 185 if ($expireAt = $paymentInstance->getSubscriptionCancelAtTimeStamp()) {
131 186 // $stripeSubscriptionData['cancel_at'] = $expireAt;
132 187 }
133 188
134 - $stripeSubscription = (new API())->createStripeObject('subscriptions', $stripeSubscriptionData);
189 + // Duplicate-charge defense — key construction contract in
190 + // .claude/skills/coding-rules/payment-idempotency.md. Seed dedupes duplicates
191 + // and frees retries; fingerprint = charge-material params so an edited order
192 + // gets a fresh key instead of a same-key/changed-parameters 400 (the abandoned
193 + // incomplete subscription auto-expires). Params, not transaction->total: a
194 + // recurring coupon can change the plan while the first charge stays $0.
195 + // Metadata excluded — volatile filters must not change the key on a duplicate.
196 + // Guard runs here, after the create body is built, so it can compare it.
197 + $existingRemoteSubscription = $this->guardExistingRemoteSubscription($subscriptionModel, $paymentInstance->order, $stripeSubscriptionData);
198 + if (is_wp_error($existingRemoteSubscription)) {
199 + return $existingRemoteSubscription;
200 + }
135 201
202 + $idempotencyFingerprint = [
203 + 'customer' => Arr::get($stripeSubscriptionData, 'customer'),
204 + 'items' => Arr::get($stripeSubscriptionData, 'items'),
205 + 'add_invoice_items' => Arr::get($stripeSubscriptionData, 'add_invoice_items'),
206 + 'trial_end' => Arr::get($stripeSubscriptionData, 'trial_end'),
207 + 'replaces' => (string)Arr::get(
208 + (array)$subscriptionModel->config,
209 + 'stripe_replaced_vendor_sub_id',
210 + ''
211 + ),
212 + ];
213 + $idempotencySeed = $paymentInstance->getIdempotencySeed();
214 + $idempotencyKey = $idempotencySeed
215 + ? 'fct_stripe_sub_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
216 + : null;
217 +
218 + if ($existingRemoteSubscription) {
219 + $stripeSubscription = $existingRemoteSubscription;
220 + } else {
221 + $stripeSubscription = (new API())->createStripeObject('subscriptions', $stripeSubscriptionData, 'current', [
222 + 'Idempotency-Key' => $idempotencyKey
223 + ]);
224 + }
225 +
136 226 if (is_wp_error($stripeSubscription)) {
137 227 return $stripeSubscription;
138 228 }
139 229
230 + // A guard-reused sub carries an expanded payment_intent object here.
140 231 $vendorChargeId = Arr::get($stripeSubscription, 'latest_invoice.payment_intent');
232 + if (is_array($vendorChargeId)) {
233 + $vendorChargeId = Arr::get($vendorChargeId, 'id');
234 + }
141 235 if (!$vendorChargeId) {
142 236 $vendorChargeId = Arr::get($stripeSubscription, 'pending_setup_intent.id');
143 237 }
144 238
@@ -147,17 +241,24 @@
147 241 }
148 242
149 243 $vendorSubscriptionId = Arr::get($stripeSubscription, 'id');
150 244
151 - $subscriptionModel->update([
245 + $subscriptionUpdateFields = [
152 246 'vendor_subscription_id' => $vendorSubscriptionId,
153 247 'vendor_customer_id' => $stripeSubscription['customer']
154 - ]);
248 + ];
155 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 +
156 256 if ($stripeSubscription['pending_setup_intent'] != null) {
157 257 $paymentArgs['vendor_subscription_info'] = [
158 258 'type' => 'setup',
159 - 'clientSecret' => Arr::get($stripeSubscription, 'pending_setup_intent.client_secret')
259 + 'clientSecret' => Arr::get($stripeSubscription, 'pending_setup_intent.client_secret'),
260 + 'trx_hash' => $paymentInstance->transaction->uuid,
160 261 ];
161 262 } else {
162 263 $paymentArgs['vendor_subscription_info'] = [
163 264 'type' => 'payment',
@@ -186,14 +287,497 @@
186 287 'fc_customer' => $customerData
187 288 ];
188 289 }
189 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 + }
190 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 +
191 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 + /**
192 604 * Handle single payment for stripe (onsite or hosted)
193 605 *
194 606 * @return \WP_Error|array
195 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 +
196 780 public function handleSinglePayment(PaymentInstance $paymentInstance, $paymentArgs = [])
197 781 {
198 782 $stripeSettings = new StripeSettingsBase();
199 783 $checkoutMode = $stripeSettings->get('checkout_mode') ?? 'onsite';
@@ -259,18 +843,49 @@
259 843 'order' => $order,
260 844 'transaction' => $transaction
261 845 ]);
262 846
263 - $intent = (new API())->createStripeObject('payment_intents', $intentData);
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 + }
264 854
265 - if (is_wp_error($intent)) {
855 + if (!empty($intent['fct_redirect'])) {
266 856 return $intent;
267 857 }
268 858
269 - $transaction->update([
270 - 'vendor_charge_id' => $intent['id']
271 - ]);
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;
272 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 +
273 888 $customerData = [
274 889 'name' => $fcCustomer->first_name . ' ' . $fcCustomer->last_name,
275 890 'email' => $fcCustomer->email,
276 891 'address_1' => $billingAddress->address_1,
@@ -312,31 +927,25 @@
312 927 if (is_wp_error($stripeCustomer)) {
313 928 return $stripeCustomer;
314 929 }
315 930
316 - // Use a single line item with the total amount to avoid complexity
317 - // This is simpler and prevents any calculation mismatches
318 - $storeName = (new \FluentCart\Api\StoreSettings())->get('store_name');
319 - $lineItems = [
320 - [
321 - 'price_data' => [
322 - 'currency' => strtolower($transactionCurrency),
323 - 'product_data' => [
324 - 'name' => $storeName . ' - Order #' . $order->uuid,
325 - 'description' => sprintf(__('Order total including all items, shipping (If any), and taxes (If any)', 'fluent-cart')),
326 - ],
327 - 'unit_amount' => $chargeAmount,
328 - ],
329 - 'quantity' => 1,
330 - ]
331 - ];
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;
332 937
938 + if (!$usedBreakdown) {
939 + $lineItems = $this->aggregateHostedLineItems($order, $transactionCurrency, $chargeAmount);
940 + }
941 +
333 942 $sessionData = [
334 943 'customer' => $stripeCustomer['id'],
335 944 'client_reference_id' => $order->uuid,
336 945 'line_items' => $lineItems,
337 946 'mode' => 'payment',
338 - 'success_url' => Arr::get($paymentArgs, 'success_url') . '&fct_stripe_hosted=1&trx_hash=' . $transaction->uuid,
947 + 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
339 948 'cancel_url' => StripeHelper::getCancelUrl(),
340 949 'metadata' => [
341 950 'fct_ref_id' => $order->uuid,
342 951 'transaction_hash' => $transaction->uuid,
@@ -343,8 +952,22 @@
343 952 'order_reference' => 'fct_order_id_' . $order->id,
344 953 ],
345 954 ];
346 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 +
347 970 $itemCount = 1;
348 971 foreach($order->order_items as $item) {
349 972 $sessionData['metadata']['item ' . $itemCount] = 'Name: ' . $item->title . ', ' . 'Qty: ' . $item->quantity . ', Price: ' . Helper::toDecimal($item->line_total, false, null, true, true, false);
350 973 if (count($sessionData['metadata']) > 49) {
@@ -353,15 +976,61 @@
353 976
354 977 $itemCount++;
355 978 }
356 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 +
357 984 $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $sessionData, [
358 985 'order' => $order,
359 986 'transaction' => $transaction
360 987 ]);
361 988
362 - $session = (new API())->createStripeObject('checkout/sessions', $sessionData);
989 + $idempotencySeed = $paymentInstance->getIdempotencySeed();
990 + $idempotencyKey = $this->hostedSessionIdempotencyKey($idempotencySeed, $sessionData);
363 991
992 + $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
993 + 'Idempotency-Key' => $idempotencyKey
994 + ]);
995 +
996 + // A breakdown adds validation surface the single aggregate line does not
997 + // have (line-item cap, product naming, per-item amounts). If Stripe rejects
998 + // the itemised body, fall back to the aggregate rather than failing the
999 + // buyer's checkout. Only a structural rejection qualifies: a transport
1000 + // failure may mean the session was in fact created, and retrying that under
1001 + // a different key would abandon the idempotency guarantee for no reason.
1002 + if ($usedBreakdown && $this->isStripeValidationError($session)) {
1003 + fluent_cart_warning_log(
1004 + 'Stripe checkout line-item breakdown rejected',
1005 + 'Stripe rejected the itemised checkout session; retrying with a single aggregate line item. Reason: ' . $session->get_error_message(),
1006 + [
1007 + 'module_name' => 'order',
1008 + 'module_id' => $order->id,
1009 + 'log_type' => 'api'
1010 + ]
1011 + );
1012 +
1013 + // Re-filter the aggregate body instead of swapping line_items inside the
1014 + // filtered one: a subscriber that derives anything from line_items (per
1015 + // line tax_rates, automatic_tax, its own totals) decided that against the
1016 + // itemised body, and editing underneath it leaves the two disagreeing.
1017 + // Rebuilt from the unfiltered base so a subscriber that appends rather
1018 + // than replaces does not apply twice.
1019 + $baseSessionData['line_items'] = $this->aggregateHostedLineItems($order, $transactionCurrency, $chargeAmount);
1020 +
1021 + $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $baseSessionData, [
1022 + 'order' => $order,
1023 + 'transaction' => $transaction
1024 + ]);
1025 +
1026 + $idempotencyKey = $this->hostedSessionIdempotencyKey($idempotencySeed, $sessionData);
1027 +
1028 + $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
1029 + 'Idempotency-Key' => $idempotencyKey
1030 + ]);
1031 + }
1032 +
364 1033 if (is_wp_error($session)) {
365 1034 return $session;
366 1035 }
367 1036
@@ -384,8 +1053,455 @@
384 1053 ];
385 1054 }
386 1055
387 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 +
388 1504 private function handleHostedSubscriptionCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
389 1505 {
390 1506 $order = $paymentInstance->order;
391 1507 $transaction = $paymentInstance->transaction;
@@ -395,8 +1511,15 @@
395 1511 if (!$subscriptionModel) {
396 1512 return new \WP_Error('no_subscription', __('No subscription found.', 'fluent-cart'));
397 1513 }
398 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 +
399 1522 $transactionCurrency = $transaction->currency;
400 1523 $orderType = $order->type;
401 1524
402 1525 // Create or get Stripe customer
@@ -442,9 +1565,10 @@
442 1565 $initialAmount = 0;
443 1566 }
444 1567
445 1568 $recurringTotal = (int)$subscriptionModel->recurring_total;
446 - if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
1569 + $isZeroDecimal = $transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency);
1570 + if ($isZeroDecimal) {
447 1571 $initialAmount = (int)($initialAmount / 100);
448 1572 $recurringTotal = (int)($recurringTotal / 100);
449 1573 }
450 1574
@@ -469,27 +1593,32 @@
469 1593 if (!empty($stripePlan['trial_period_days'])) {
470 1594 $subscriptionData['trial_period_days'] = $stripePlan['trial_period_days'];
471 1595 }
472 1596
1597 + // can add billing cycle anchor config here, if we allow billing anchor in fluent-cart subscription
1598 +
473 1599 if ($initialAmount > 0) {
474 - $addonPrice = Plan::getOneTimeAddonPrice([
475 - 'product_id' => $subscriptionModel->product_id,
476 - 'currency' => $order->currency,
477 - 'amount' => (int)$initialAmount,
478 - 'name' => __('Signup fee / initial payment', 'fluent-cart'),
479 - 'variation_id' => $subscriptionModel->variation_id,
480 - 'order_id' => $subscriptionModel->parent_order_id,
1600 + $stripeCurrency = strtolower($order->currency);
481 1601
482 - ]);
1602 + $initialItems = $this->buildHostedSubscriptionInitialLineItems(
1603 + $paymentInstance,
1604 + $stripeCurrency,
1605 + $isZeroDecimal,
1606 + (int)$initialAmount,
1607 + count($lineItems)
1608 + );
483 1609
484 - if (is_wp_error($addonPrice)) {
485 - return $addonPrice;
486 - };
1610 + if ($initialItems === null) {
1611 + $initialItems = [
1612 + $this->hostedFlatLineItem(
1613 + $this->hostedSubscriptionInitialName($order, $subscriptionModel),
1614 + $stripeCurrency,
1615 + (int)$initialAmount
1616 + )
1617 + ];
1618 + }
487 1619
488 - $lineItems[] = [
489 - 'price' => $addonPrice['id'],
490 - 'quantity' => 1
491 - ];
1620 + $lineItems = array_merge($lineItems, $initialItems);
492 1621 }
493 1622
494 1623 $sessionData = [
495 1624 'customer' => $stripeCustomer['id'],
@@ -496,11 +1625,14 @@
496 1625 'client_reference_id' => $order->uuid,
497 1626 'line_items' => $lineItems,
498 1627 'mode' => 'subscription',
499 1628 'consent_collection' => ['payment_method_reuse_agreement' => ['position' => 'hidden']],
500 - 'success_url' => Arr::get($paymentArgs, 'success_url') . '&fct_stripe_hosted=1&trx_hash=' . $transaction->uuid,
1629 + 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
501 1630 'cancel_url' => StripeHelper::getCancelUrl(),
502 1631 'subscription_data' => $subscriptionData,
1632 + 'saved_payment_method_options' => [
1633 + 'payment_method_save' => 'enabled'
1634 + ],
503 1635 'metadata' => [
504 1636 'fct_ref_id' => $order->uuid,
505 1637 'subscription_item' => $subscriptionModel->item_name,
506 1638 'transaction_hash' => $transaction->uuid,
@@ -507,8 +1639,13 @@
507 1639 'order_reference' => 'fct_order_id_' . $order->id,
508 1640 ],
509 1641 ];
510 1642
1643 + $submitType = (new StripeSettingsBase())->getSubmitType();
1644 + if ($submitType && in_array($submitType, ['donate', 'subscribe', 'auto'], true)) {
1645 + $sessionData['submit_type'] = $submitType;
1646 + }
1647 +
511 1648 $sessionData = apply_filters('fluent_cart/payments/stripe_subscription_checkout_session_args', $sessionData, [
512 1649 'order' => $order,
513 1650 'transaction' => $transaction,
514 1651 'subscription' => $subscriptionModel
@@ -513,9 +1650,32 @@
513 1650 'transaction' => $transaction,
514 1651 'subscription' => $subscriptionModel
515 1652 ]);
516 1653
517 - $session = (new API())->createStripeObject('checkout/sessions', $sessionData);
1654 + // Same duplicate-subscription defense as the onsite path, applied to the hosted
1655 + // Checkout Session. Metadata is excluded so a volatile metadata filter cannot
1656 + // change the key on a genuine duplicate and reopen the double-charge window.
1657 + $idempotencyFingerprint = [
1658 + 'customer' => Arr::get($sessionData, 'customer'),
1659 + 'line_items' => Arr::get($sessionData, 'line_items'),
1660 + 'mode' => Arr::get($sessionData, 'mode'),
1661 + 'subscription_data' => Arr::get($sessionData, 'subscription_data'),
1662 + // See the onsite path: rolls the key after a guard cancel, read from
1663 + // the persisted marker so retries recompute the same key.
1664 + 'replaces' => (string)Arr::get(
1665 + (array)$subscriptionModel->config,
1666 + 'stripe_replaced_vendor_sub_id',
1667 + ''
1668 + ),
1669 + ];
1670 + $idempotencySeed = $paymentInstance->getIdempotencySeed();
1671 + $idempotencyKey = $idempotencySeed
1672 + ? 'fct_stripe_sub_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
1673 + : null;
1674 +
1675 + $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
1676 + 'Idempotency-Key' => $idempotencyKey
1677 + ]);
518 1678
519 1679 if (is_wp_error($session)) {
520 1680 return $session;
521 1681 }