PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | app/Modules/PaymentMethods/StripeGateway/Processor.php +787 -75 1.6.1 → 1.6.5 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
@@ -238,20 +292,52 @@
238 292 * A retryable checkout can arrive with a live Stripe subscription already
239 293 * attached — the previous create succeeded but its confirm/webhook never
240 294 * landed, and a changed cart mints a fresh idempotency key, so the key alone
241 295 * cannot stop a second create. A second create bills the customer on a
242 - * subscription the store cannot see or cancel. Billing-active remote: block
243 - * the create and re-sync local state from Stripe. Unconfirmed incomplete
244 - * 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
245 324 */
246 - private function guardExistingRemoteSubscription($subscriptionModel)
325 + private function guardExistingRemoteSubscription($subscriptionModel, $order, $requestData = [])
247 326 {
248 327 $existingVendorSubId = $subscriptionModel->vendor_subscription_id;
249 - if (!$existingVendorSubId) {
328 + if (!$existingVendorSubId || strpos($existingVendorSubId, 'sub_') !== 0) {
250 329 return null;
251 330 }
252 331
253 - $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 +
254 340 if (is_wp_error($remoteSub)) {
255 341 return null;
256 342 }
257 343
@@ -257,8 +343,21 @@
257 343
258 344 $remoteStatus = Arr::get($remoteSub, 'status');
259 345
260 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 +
261 360 (new StripeSubscriptions())->reSyncSubscriptionFromRemote($subscriptionModel);
262 361 return new \WP_Error(
263 362 'stripe_subscription_already_active',
264 363 __('Subscription is already active. Please refresh this page to see the status instead of trying again.', 'fluent-cart')
@@ -264,28 +363,140 @@
264 363 __('Subscription is already active. Please refresh this page to see the status instead of trying again.', 'fluent-cart')
265 364 );
266 365 }
267 366
268 - if (in_array($remoteStatus, ['incomplete', 'unpaid'], true)) {
269 - $cancelResponse = (new API())->deleteStripeObject('subscriptions/' . $existingVendorSubId, [], 'current');
270 - if (is_wp_error($cancelResponse)) {
271 - fluent_cart_warning_log(
272 - 'Stripe stale ' . $remoteStatus . ' subscription cancel failed',
273 - $cancelResponse->get_error_message() . ' (' . $existingVendorSubId . ')',
274 - [
275 - 'module_type' => 'FluentCart\App\Models\Subscription',
276 - 'module_id' => $subscriptionModel->id,
277 - 'module_name' => 'subscription',
278 - 'log_type' => 'api'
279 - ]
280 - );
281 - }
367 + if (Arr::get($remoteSub, 'metadata.fct_ref_id') !== $order->uuid) {
368 + return null;
282 369 }
283 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 +
284 413 return null;
285 414 }
286 415
287 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 + /**
288 499 * One-time analogue of guardExistingRemoteSubscription(). A resubmit whose
289 500 * charge-material params changed (or whose key aged past Stripe's 24h window)
290 501 * would mint a second PaymentIntent while the first stays confirmable in any
291 502 * stale tab — and a charge on that orphan is dropped by the webhook with no
@@ -345,9 +556,9 @@
345 556 // a page that has no idea their order is paid.
346 557 return [
347 558 'fct_redirect' => true,
348 559 'status' => 'success',
349 - 'redirect_to' => $transaction->getReceiptPageUrl(),
560 + 'redirect_to' => $transaction->getSuccessUrl(),
350 561 'message' => __('Your payment has already been processed. Redirecting to your order...', 'fluent-cart')
351 562 ];
352 563 }
353 564
@@ -510,9 +721,9 @@
510 721 'customer' => $stripeCustomer['id'],
511 722 'client_reference_id' => $order->uuid,
512 723 'mode' => 'setup',
513 724 'currency' => strtolower($transactionCurrency),
514 - 'success_url' => Arr::get($paymentArgs, 'success_url') . '&fct_stripe_hosted=1&trx_hash=' . $transaction->uuid,
725 + 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
515 726 'cancel_url' => StripeHelper::getCancelUrl(),
516 727 'metadata' => [
517 728 'fct_ref_id' => $order->uuid,
518 729 'transaction_hash' => $transaction->uuid,
@@ -716,31 +927,25 @@
716 927 if (is_wp_error($stripeCustomer)) {
717 928 return $stripeCustomer;
718 929 }
719 930
720 - // Use a single line item with the total amount to avoid complexity
721 - // This is simpler and prevents any calculation mismatches
722 - $storeName = (new \FluentCart\Api\StoreSettings())->get('store_name');
723 - $lineItems = [
724 - [
725 - 'price_data' => [
726 - 'currency' => strtolower($transactionCurrency),
727 - 'product_data' => [
728 - 'name' => $storeName . ' - Order #' . $order->uuid,
729 - 'description' => sprintf(__('Order total including all items, shipping (If any), and taxes (If any)', 'fluent-cart')),
730 - ],
731 - 'unit_amount' => $chargeAmount,
732 - ],
733 - 'quantity' => 1,
734 - ]
735 - ];
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;
736 937
938 + if (!$usedBreakdown) {
939 + $lineItems = $this->aggregateHostedLineItems($order, $transactionCurrency, $chargeAmount);
940 + }
941 +
737 942 $sessionData = [
738 943 'customer' => $stripeCustomer['id'],
739 944 'client_reference_id' => $order->uuid,
740 945 'line_items' => $lineItems,
741 946 'mode' => 'payment',
742 - 'success_url' => Arr::get($paymentArgs, 'success_url') . '&fct_stripe_hosted=1&trx_hash=' . $transaction->uuid,
947 + 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
743 948 'cancel_url' => StripeHelper::getCancelUrl(),
744 949 'metadata' => [
745 950 'fct_ref_id' => $order->uuid,
746 951 'transaction_hash' => $transaction->uuid,
@@ -756,8 +961,13 @@
756 961 'setup_future_usage' => $paymentArgs['setup_future_usage'],
757 962 ];
758 963 }
759 964
965 + $submitType = (new StripeSettingsBase())->getSubmitType();
966 + if ($submitType && in_array($submitType, ['auto', 'book', 'donate', 'pay'], true)) {
967 + $sessionData['submit_type'] = $submitType;
968 + }
969 +
760 970 $itemCount = 1;
761 971 foreach($order->order_items as $item) {
762 972 $sessionData['metadata']['item ' . $itemCount] = 'Name: ' . $item->title . ', ' . 'Qty: ' . $item->quantity . ', Price: ' . Helper::toDecimal($item->line_total, false, null, true, true, false);
763 973 if (count($sessionData['metadata']) > 49) {
@@ -766,30 +976,61 @@
766 976
767 977 $itemCount++;
768 978 }
769 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 +
770 984 $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $sessionData, [
771 985 'order' => $order,
772 986 'transaction' => $transaction
773 987 ]);
774 988
775 - // Same duplicate-charge defense as every other Stripe create path: a pure
776 - // duplicate replays the key and gets the original session back; an edited-cart
777 - // resubmit gets a fresh key instead of a same-key/changed-parameters 400.
778 - $idempotencyFingerprint = [
779 - 'customer' => Arr::get($sessionData, 'customer'),
780 - 'line_items' => Arr::get($sessionData, 'line_items'),
781 - 'mode' => Arr::get($sessionData, 'mode'),
782 - ];
783 989 $idempotencySeed = $paymentInstance->getIdempotencySeed();
784 - $idempotencyKey = $idempotencySeed
785 - ? 'fct_stripe_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
786 - : null;
990 + $idempotencyKey = $this->hostedSessionIdempotencyKey($idempotencySeed, $sessionData);
787 991
788 992 $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
789 993 'Idempotency-Key' => $idempotencyKey
790 994 ]);
791 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 +
792 1033 if (is_wp_error($session)) {
793 1034 return $session;
794 1035 }
795 1036
@@ -812,8 +1053,455 @@
812 1053 ];
813 1054 }
814 1055
815 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 +
816 1504 private function handleHostedSubscriptionCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
817 1505 {
818 1506 $order = $paymentInstance->order;
819 1507 $transaction = $paymentInstance->transaction;
@@ -823,9 +1511,12 @@
823 1511 if (!$subscriptionModel) {
824 1512 return new \WP_Error('no_subscription', __('No subscription found.', 'fluent-cart'));
825 1513 }
826 1514
827 - 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)) {
828 1519 return $guardError;
829 1520 }
830 1521
831 1522 $transactionCurrency = $transaction->currency;
@@ -874,9 +1565,10 @@
874 1565 $initialAmount = 0;
875 1566 }
876 1567
877 1568 $recurringTotal = (int)$subscriptionModel->recurring_total;
878 - if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
1569 + $isZeroDecimal = $transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency);
1570 + if ($isZeroDecimal) {
879 1571 $initialAmount = (int)($initialAmount / 100);
880 1572 $recurringTotal = (int)($recurringTotal / 100);
881 1573 }
882 1574
@@ -901,27 +1593,32 @@
901 1593 if (!empty($stripePlan['trial_period_days'])) {
902 1594 $subscriptionData['trial_period_days'] = $stripePlan['trial_period_days'];
903 1595 }
904 1596
1597 + // can add billing cycle anchor config here, if we allow billing anchor in fluent-cart subscription
1598 +
905 1599 if ($initialAmount > 0) {
906 - $addonPrice = Plan::getOneTimeAddonPrice([
907 - 'product_id' => $subscriptionModel->product_id,
908 - 'currency' => $order->currency,
909 - 'amount' => (int)$initialAmount,
910 - 'name' => __('Signup fee / initial payment', 'fluent-cart'),
911 - 'variation_id' => $subscriptionModel->variation_id,
912 - 'order_id' => $subscriptionModel->parent_order_id,
1600 + $stripeCurrency = strtolower($order->currency);
913 1601
914 - ]);
1602 + $initialItems = $this->buildHostedSubscriptionInitialLineItems(
1603 + $paymentInstance,
1604 + $stripeCurrency,
1605 + $isZeroDecimal,
1606 + (int)$initialAmount,
1607 + count($lineItems)
1608 + );
915 1609
916 - if (is_wp_error($addonPrice)) {
917 - return $addonPrice;
918 - };
1610 + if ($initialItems === null) {
1611 + $initialItems = [
1612 + $this->hostedFlatLineItem(
1613 + $this->hostedSubscriptionInitialName($order, $subscriptionModel),
1614 + $stripeCurrency,
1615 + (int)$initialAmount
1616 + )
1617 + ];
1618 + }
919 1619
920 - $lineItems[] = [
921 - 'price' => $addonPrice['id'],
922 - 'quantity' => 1
923 - ];
1620 + $lineItems = array_merge($lineItems, $initialItems);
924 1621 }
925 1622
926 1623 $sessionData = [
927 1624 'customer' => $stripeCustomer['id'],
@@ -928,11 +1625,14 @@
928 1625 'client_reference_id' => $order->uuid,
929 1626 'line_items' => $lineItems,
930 1627 'mode' => 'subscription',
931 1628 'consent_collection' => ['payment_method_reuse_agreement' => ['position' => 'hidden']],
932 - 'success_url' => Arr::get($paymentArgs, 'success_url') . '&fct_stripe_hosted=1&trx_hash=' . $transaction->uuid,
1629 + 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
933 1630 'cancel_url' => StripeHelper::getCancelUrl(),
934 1631 'subscription_data' => $subscriptionData,
1632 + 'saved_payment_method_options' => [
1633 + 'payment_method_save' => 'enabled'
1634 + ],
935 1635 'metadata' => [
936 1636 'fct_ref_id' => $order->uuid,
937 1637 'subscription_item' => $subscriptionModel->item_name,
938 1638 'transaction_hash' => $transaction->uuid,
@@ -939,8 +1639,13 @@
939 1639 'order_reference' => 'fct_order_id_' . $order->id,
940 1640 ],
941 1641 ];
942 1642
1643 + $submitType = (new StripeSettingsBase())->getSubmitType();
1644 + if ($submitType && in_array($submitType, ['donate', 'subscribe', 'auto'], true)) {
1645 + $sessionData['submit_type'] = $submitType;
1646 + }
1647 +
943 1648 $sessionData = apply_filters('fluent_cart/payments/stripe_subscription_checkout_session_args', $sessionData, [
944 1649 'order' => $order,
945 1650 'transaction' => $transaction,
946 1651 'subscription' => $subscriptionModel
@@ -953,8 +1658,15 @@
953 1658 'customer' => Arr::get($sessionData, 'customer'),
954 1659 'line_items' => Arr::get($sessionData, 'line_items'),
955 1660 'mode' => Arr::get($sessionData, 'mode'),
956 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 + ),
957 1669 ];
958 1670 $idempotencySeed = $paymentInstance->getIdempotencySeed();
959 1671 $idempotencyKey = $idempotencySeed
960 1672 ? 'fct_stripe_sub_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))