PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 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 All 51 releases
← All changes | app/Modules/PaymentMethods/StripeGateway/Confirmations.php +473 -25 1.6.1 → 1.7.1 View file →
@@ -32,26 +32,375 @@
32 32 return $value;
33 33 }, 10, 2);
34 34
35 35
36 - if (isset($_REQUEST['fct_stripe_hosted']) && isset($_REQUEST['trx_hash'])) {
37 - $transaction = OrderTransaction::query()->where('uuid', sanitize_text_field(App::request()->get('trx_hash')))->first();
38 - if (!$transaction || $transaction->status === Status::TRANSACTION_SUCCEEDED) {
39 - return;
40 - }
36 + // Browser return from Stripe hosted checkout, dispatched by core
37 + // WebRoutes (?fluent-cart=fct_stripe_hosted): confirm first, then
38 + // send the buyer to the filterable success URL.
39 + add_action('fluent_cart_action_fct_stripe_hosted', [$this, 'handleHostedReturn']);
41 40
41 + // Browser return from an issuer-forced 3DS redirect on an onsite confirm
42 + // (?fluent-cart=fct_stripe_onsite_return), dispatched the same way.
43 + add_action('fluent_cart_action_fct_stripe_onsite_return', [$this, 'handleOnsiteRedirectReturn']);
44 +
45 + }
46 +
47 + /**
48 + * Confirm a hosted-checkout session on the buyer's return, then redirect.
49 + * The gateway return URL is internal and unfiltered; the buyer's real
50 + * destination (fluent_cart/payment/success_url) applies only after
51 + * confirmation has run — so a filter that sends buyers to another page
52 + * can never break payment confirmation.
53 + */
54 + public function handleHostedReturn($requestData)
55 + {
56 + $transaction = OrderTransaction::query()
57 + ->where('uuid', sanitize_text_field(Arr::get($requestData, 'trx_hash', '')))
58 + ->first();
59 +
60 + if (!$transaction) {
61 + wp_redirect(home_url());
62 + exit;
63 + }
64 +
65 + if ($transaction->status !== Status::TRANSACTION_SUCCEEDED) {
42 66 // Get session ID from transaction meta
43 67 $sessionId = Arr::get($transaction->meta, 'session_id');
44 -
68 +
45 69 if ($sessionId) {
46 70 $this->confirmByCheckoutSession($sessionId, $transaction);
47 - } else {
48 - return;
49 71 }
50 - }
72 + }
51 73
74 + // Re-query: confirmByCheckoutSession updates the row, not this instance.
75 + $freshTransaction = OrderTransaction::query()->find($transaction->id);
76 + if ($freshTransaction && $freshTransaction->status === Status::TRANSACTION_SUCCEEDED) {
77 + wp_redirect($this->getHostedReturnRedirectUrl($freshTransaction));
78 + exit;
79 + }
80 +
81 + // Not confirmed (pending, failed, or no session yet): land on the
82 + // receipt page, which renders the order's current state.
83 + wp_redirect($transaction->getReceiptPageUrl());
84 + exit;
52 85 }
53 -
86 +
87 + /**
88 + * Where the buyer lands after a confirmed hosted-checkout return.
89 + */
90 + public function getHostedReturnRedirectUrl($transaction)
91 + {
92 + return $transaction->getSuccessUrl();
93 + }
94 +
95 + /**
96 + * Confirm an onsite payment on the buyer's return from a 3DS redirect.
97 + *
98 + * Onsite confirms with `redirect: 'if_required'`, so the challenge normally
99 + * renders inline and the page never navigates. Some issuers force a full
100 + * redirect to their ACS page instead; Stripe then sends the buyer to the
101 + * return_url with `payment_intent` / `setup_intent` appended. Same contract
102 + * as the hosted return: an internal, unfiltered URL confirms first, and the
103 + * buyer's real destination is applied afterwards.
104 + */
105 + public function handleOnsiteRedirectReturn($requestData)
106 + {
107 + $vendorIntentId = Arr::get($requestData, 'payment_intent');
108 + if (!$vendorIntentId) {
109 + $vendorIntentId = Arr::get($requestData, 'setup_intent');
110 + }
111 +
112 + $trxHash = sanitize_text_field((string) Arr::get($requestData, 'trx_hash', ''));
113 +
114 + $this->confirmRedirectReturn($trxHash, $vendorIntentId);
115 +
116 + $transaction = OrderTransaction::query()->where('uuid', $trxHash)->first();
117 +
118 + if (!$transaction) {
119 + wp_redirect(home_url());
120 + exit;
121 + }
122 +
123 + if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
124 + wp_redirect($this->getHostedReturnRedirectUrl($transaction));
125 + exit;
126 + }
127 +
128 + wp_redirect($transaction->getReceiptPageUrl());
129 + exit;
130 + }
131 +
132 + /**
133 + * Confirm an onsite payment the buyer completed through a 3DS redirect.
134 + *
135 + * Unauthenticated surface: the return URL is a plain GET the buyer's browser
136 + * follows, so nothing here trusts the caller. `redirect_status` is ignored
137 + * entirely — the intent is re-fetched for its authoritative status — and the
138 + * intent must both be shaped like a Stripe id and match the one we stamped on
139 + * the transaction the hash resolves to.
140 + *
141 + * @param string $trxHash
142 + * @param string $vendorIntentId
143 + * @return bool whether the payment was confirmed
144 + */
145 + public function confirmRedirectReturn($trxHash, $vendorIntentId)
146 + {
147 + $trxHash = sanitize_text_field((string) $trxHash);
148 + $vendorIntentId = sanitize_text_field((string) $vendorIntentId);
149 +
150 + if (!$trxHash || !preg_match('/^(pi|seti)_[a-zA-Z0-9_]+$/', $vendorIntentId)) {
151 + return false;
152 + }
153 +
154 + $transaction = OrderTransaction::query()->where('uuid', $trxHash)->first();
155 + if (!$transaction || $this->isSettledTransaction($transaction->status)) {
156 + return false;
157 + }
158 +
159 + if ((string) $transaction->vendor_charge_id !== $vendorIntentId) {
160 + return false;
161 + }
162 +
163 + if (strpos($vendorIntentId, 'seti_') === 0) {
164 + return !is_wp_error($this->confirmSetupIntent($vendorIntentId, $trxHash));
165 + }
166 +
167 + $intent = (new API())->getStripeObject('payment_intents/' . $vendorIntentId, [
168 + 'expand' => ['latest_charge']
169 + ]);
170 +
171 + if (is_wp_error($intent)) {
172 + fluent_cart_add_log(__('Stripe Payment Intent Retrieval Failed', 'fluent-cart'), $intent->get_error_message(), 'error', [
173 + 'module_name' => 'order',
174 + 'module_id' => $transaction->order_id,
175 + ]);
176 + return false;
177 + }
178 +
179 + return $this->applyIntentOutcome($transaction, $vendorIntentId, $intent);
180 + }
181 +
182 + /**
183 + * Record a terminal PaymentIntent outcome against its transaction.
184 + *
185 + * A failed confirm has to land as `failed`, not stay `pending`:
186 + * `CheckoutProcessor` bumps `payment_attempt` only for a failed transaction,
187 + * and without that bump the retry reuses the same idempotency seed and
188 + * replays Stripe's 24h-cached response for a subscription the create-guard
189 + * has since deleted.
190 + *
191 + * @param string $intentId
192 + * @param array $intent
193 + * @param bool $markFailed set false when the caller has not proven the
194 + * reporter owns this transaction
195 + * @return bool
196 + */
197 + protected function applyIntentOutcome(OrderTransaction $transaction, $intentId, $intent, $markFailed = true)
198 + {
199 + // Both entry points are buyer-replayable — the return URL can be revisited
200 + // and the failure report is a nopriv POST — and the caller's model was
201 + // loaded before a Stripe round-trip of hundreds of milliseconds, so a
202 + // refund landing inside that window has to win.
203 + $transaction = OrderTransaction::query()->find($transaction->id);
204 +
205 + if (!$transaction) {
206 + return false;
207 + }
208 +
209 + if ($this->isSettledTransaction($transaction->status)) {
210 + return $transaction->status === Status::TRANSACTION_SUCCEEDED;
211 + }
212 +
213 + $status = Arr::get($intent, 'status');
214 + $failure = $this->intentFailureContext($status, Arr::get($intent, 'last_payment_error', []));
215 +
216 + if (in_array($status, ['requires_payment_method', 'canceled'], true)) {
217 + if ($markFailed) {
218 + $this->markIntentFailed($transaction, $failure);
219 + }
220 +
221 + return false;
222 + }
223 +
224 + // The buyer can still finish this very intent, so leave the transaction
225 + // pending and let them — but record the stall, otherwise an abandoned
226 + // challenge leaves no trace anywhere until Stripe expires the intent.
227 + if (in_array($status, ['requires_action', 'requires_confirmation'], true)) {
228 + $this->logIntentOutcome(
229 + $transaction,
230 + $failure['is_auth_failure']
231 + ? __('Stripe 3D Secure Authentication Not Completed', 'fluent-cart')
232 + : __('Stripe Payment Not Completed', 'fluent-cart'),
233 + $failure['detail'],
234 + 'warning'
235 + );
236 +
237 + return false;
238 + }
239 +
240 + $this->confirmPaymentSuccessByCharge($transaction, [
241 + 'charge' => Arr::get($intent, 'latest_charge', []),
242 + 'intent_id' => $intentId
243 + ]);
244 +
245 + // `processing` and `requires_capture` reach here with a charge that has not
246 + // settled, and confirmPaymentSuccessByCharge leaves those pending. Reporting
247 + // them as confirmed would hand the buyer a receipt redirect for a payment
248 + // nobody has taken, so read back what actually landed.
249 + $settled = OrderTransaction::query()->find($transaction->id);
250 +
251 + return $settled && $settled->status === Status::TRANSACTION_SUCCEEDED;
252 + }
253 +
254 + /**
255 + * Classify a Stripe intent failure and build the line written to the log.
256 + *
257 + * Stripe reports an abandoned or rejected 3DS challenge as
258 + * payment_intent_authentication_failure / setup_intent_authentication_failure /
259 + * authentication_required. It is the single largest cause of a first attempt
260 + * that never completes, so it earns its own title rather than a generic
261 + * decline line.
262 + *
263 + * @param string $status
264 + * @param array $error `last_payment_error` or `last_setup_error`
265 + * @return array{is_auth_failure: bool, detail: string}
266 + */
267 + protected function intentFailureContext($status, $error)
268 + {
269 + if (!is_array($error)) {
270 + $error = [];
271 + }
272 +
273 + $code = (string) Arr::get($error, 'code', '');
274 + $declineCode = (string) Arr::get($error, 'decline_code', '');
275 +
276 + return [
277 + 'is_auth_failure' => strpos($code, 'authentication') !== false
278 + || $declineCode === 'authentication_required',
279 + 'detail' => sprintf(
280 + /* translators: 1: Stripe payment intent status, 2: Stripe error message */
281 + __('Stripe reported the payment intent as %1$s. %2$s', 'fluent-cart'),
282 + $status,
283 + Arr::get($error, 'message', '')
284 + ),
285 + ];
286 + }
287 +
288 + /**
289 + * What actually landed on the row, for the browser's failure report to read.
290 + * It may only re-enable checkout once the transaction is genuinely terminal,
291 + * and the HTTP status cannot say that — a 400 is also how "invalid request"
292 + * and an unfinished challenge answer.
293 + *
294 + * @param OrderTransaction|null $transaction
295 + * @return string
296 + */
297 + protected function reportedTransactionStatus($transaction)
298 + {
299 + if (!$transaction) {
300 + return '';
301 + }
302 +
303 + $fresh = OrderTransaction::query()->find($transaction->id);
304 +
305 + return (string) ($fresh ? $fresh->status : $transaction->status);
306 + }
307 +
308 + /**
309 + * Statuses downstream of a completed payment. Owned by refunds, disputes and
310 + * webhooks — never writable by a confirmation, which can always arrive with a
311 + * charge that still reads `succeeded` at Stripe.
312 + *
313 + * @return array
314 + */
315 + protected function postPaymentStatuses()
316 + {
317 + return [
318 + Status::TRANSACTION_REFUNDED,
319 + Status::TRANSACTION_DISPUTE_LOST,
320 + ];
321 + }
322 +
323 + /**
324 + * Statuses a browser-driven confirm must never rewrite. Adds the two the
325 + * buyer's own replays would otherwise reopen: `succeeded`, and `authorized`
326 + * money Stripe is holding for a later capture.
327 + *
328 + * @return array
329 + */
330 + protected function settledTransactionStatuses()
331 + {
332 + return array_merge([
333 + Status::TRANSACTION_SUCCEEDED,
334 + Status::TRANSACTION_AUTHORIZED,
335 + ], $this->postPaymentStatuses());
336 + }
337 +
338 + /**
339 + * @param string $status
340 + * @return bool
341 + */
342 + protected function isSettledTransaction($status)
343 + {
344 + return in_array((string) $status, $this->settledTransactionStatuses(), true);
345 + }
346 +
347 + /**
348 + * @param array $failure from intentFailureContext()
349 + * @return void
350 + */
351 + protected function markIntentFailed(OrderTransaction $transaction, $failure)
352 + {
353 + // Compare-and-set, not read-then-write: a webhook can settle the row while
354 + // a stale failure report is in flight, and that report must never flip a
355 + // captured, refunded or disputed payment to `failed`. A zero row count also
356 + // covers a repeat report, keeping the log entry below from doubling.
357 + $updated = OrderTransaction::query()
358 + ->where('id', $transaction->id)
359 + ->whereNotIn('status', $this->settledTransactionStatuses())
360 + ->where('status', '!=', Status::TRANSACTION_FAILED)
361 + ->update(['status' => Status::TRANSACTION_FAILED]);
362 +
363 + if (!$updated) {
364 + return;
365 + }
366 +
367 + $transaction->status = Status::TRANSACTION_FAILED;
368 +
369 + $this->logIntentOutcome(
370 + $transaction,
371 + $failure['is_auth_failure']
372 + ? __('Stripe 3D Secure Authentication Failed', 'fluent-cart')
373 + : __('Stripe Payment Failed', 'fluent-cart'),
374 + $failure['detail'],
375 + 'error'
376 + );
377 + }
378 +
379 + /**
380 + * Mirror an intent outcome onto the Order and, when there is one, its Subscription.
381 + *
382 + * @param string $title
383 + * @param string $detail
384 + * @param string $level
385 + * @return void
386 + */
387 + protected function logIntentOutcome(OrderTransaction $transaction, $title, $detail, $level)
388 + {
389 + fluent_cart_add_log($title, $detail, $level, [
390 + 'module_name' => 'order',
391 + 'module_id' => $transaction->order_id,
392 + ]);
393 +
394 + if ($transaction->subscription_id) {
395 + fluent_cart_add_log($title, $detail, $level, [
396 + 'module_type' => 'FluentCart\App\Models\Subscription',
397 + 'module_id' => $transaction->subscription_id,
398 + 'module_name' => 'subscription',
399 + ]);
400 + }
401 + }
402 +
54 403 private function confirmByCheckoutSession($sessionId, $transaction)
55 404 {
56 405
57 406 $api = new API();
@@ -256,9 +605,12 @@
256 605 $result = $this->confirmSetupIntent($intentId, $trxHash);
257 606 if (is_wp_error($result)) {
258 607 wp_send_json(
259 608 [
260 - 'message' => $result->get_error_message(),
609 + 'message' => $result->get_error_message(),
610 + 'transaction_status' => $this->reportedTransactionStatus(
611 + OrderTransaction::query()->where('uuid', $trxHash)->first()
612 + ),
261 613 ], 400
262 614 );
263 615 }
264 616 wp_send_json(
@@ -292,16 +644,44 @@
292 644 404
293 645 );
294 646 }
295 647
296 - $this->confirmPaymentSuccessByCharge($transaction, [
297 - 'charge' => Arr::get($response, 'latest_charge', []),
298 - 'intent_id' => $intentId
299 - ]);
648 + // This action is nopriv and carries no nonce, so a reporter may only
649 + // move the transaction to `failed` when it also produced the hash we
650 + // handed the buyer. Confirming a success is safe either way — Stripe's
651 + // own status is the authority there.
652 + $reportedHash = sanitize_text_field((string) App::request()->get('trx_hash'));
653 + $ownsTransaction = $reportedHash !== '' && $reportedHash === (string) $transaction->uuid;
300 654
655 + if (!$this->applyIntentOutcome($transaction, $intentId, $response, $ownsTransaction)) {
656 + // An in-flight charge is not a decline. Telling the buyer to try again
657 + // invites a resubmit for money Stripe is already taking.
658 + if (in_array(Arr::get($response, 'status'), ['processing', 'requires_capture'], true)) {
659 + wp_send_json(
660 + [
661 + 'message' => __('Your payment is still being processed by Stripe. Please do not submit it again — we will confirm your order as soon as it settles.', 'fluent-cart'),
662 + 'transaction_status' => $this->reportedTransactionStatus($transaction),
663 + ],
664 + 400
665 + );
666 + }
667 +
668 + wp_send_json(
669 + [
670 + 'message' => Arr::get(
671 + $response,
672 + 'last_payment_error.message',
673 + __('The payment could not be completed. Please try again.', 'fluent-cart')
674 + ),
675 + 'transaction_status' => $this->reportedTransactionStatus($transaction),
676 + ],
677 + 400
678 + );
679 + }
680 +
301 681 wp_send_json(
302 682 [
303 - 'redirect_url' => $transaction->getReceiptPageUrl(),
683 + 'redirect_url' => $transaction->getSuccessUrl(),
304 684 'order' => [
305 685 'uuid' => $transaction->order->uuid,
306 686 ],
307 687 'message' => __('Payment confirmed successfully. Redirecting...!', 'fluent-cart')
@@ -392,13 +772,13 @@
392 772
393 773 $fctCustomer->updateMeta($metaKey, $meta);
394 774 }
395 775
396 - public function confirmSetupIntent($setupIntent, $trxHash = null)
776 + public function confirmSetupIntent($setupIntent, $trxHash = null, $mode = 'current')
397 777 {
398 778 $api = new API();
399 779
400 - $response = $api->getStripeObject('setup_intents/' . $setupIntent);
780 + $response = $api->getStripeObject('setup_intents/' . $setupIntent, [], $mode);
401 781
402 782 if (is_wp_error($response)) {
403 783 return $response;
404 784 }
@@ -415,9 +795,30 @@
415 795 if ($trxHash !== null && $transaction->uuid !== $trxHash) {
416 796 return new \WP_Error('invalid_request', __('Invalid request.', 'fluent-cart'));
417 797 }
418 798
419 - if (Arr::get($response, 'status') !== 'succeeded') {
799 + $setupStatus = Arr::get($response, 'status');
800 +
801 + if ($setupStatus !== 'succeeded') {
802 + // A vaulting failure carries the same idempotency consequence as a
803 + // charge failure: left pending, CheckoutProcessor never bumps
804 + // `payment_attempt`, so the retry reuses the seed and Stripe replays
805 + // its cached response for an intent that can no longer be confirmed.
806 + $failure = $this->intentFailureContext($setupStatus, Arr::get($response, 'last_setup_error', []));
807 +
808 + if (in_array($setupStatus, ['requires_payment_method', 'canceled'], true)) {
809 + $this->markIntentFailed($transaction, $failure);
810 + } else {
811 + $this->logIntentOutcome(
812 + $transaction,
813 + $failure['is_auth_failure']
814 + ? __('Stripe 3D Secure Authentication Not Completed', 'fluent-cart')
815 + : __('Stripe Payment Method Setup Not Completed', 'fluent-cart'),
816 + $failure['detail'],
817 + 'warning'
818 + );
819 + }
820 +
420 821 return new \WP_Error(
421 822 'setup_intent_not_succeeded',
422 823 __('Payment method setup is not complete. Please complete the payment method setup.', 'fluent-cart')
423 824 );
@@ -438,15 +839,15 @@
438 839
439 840 $paymentMethod = Arr::get($response, 'payment_method');
440 841 $customer = Arr::get($response, 'customer');
441 842
442 - $billingInfo = $this->getPaymentMethodDetails($paymentMethod);
843 + $billingInfo = $this->getPaymentMethodDetails($paymentMethod, $mode);
443 844
444 845 // attach the payment method to the customer
445 846 if ($paymentMethod && $customer) {
446 847 $api->createStripeObject('payment_methods/' . $paymentMethod . '/attach', [
447 848 'customer' => $customer
448 - ]);
849 + ], $mode);
449 850
450 851 $this->savePaymentMethodToCustomerMeta($customer, $paymentMethod, $order);
451 852 }
452 853
@@ -522,11 +923,11 @@
522 923 );
523 924 }
524 925 }
525 926
526 - public function getPaymentMethodDetails($methodId)
927 + public function getPaymentMethodDetails($methodId, $mode = 'current')
527 928 {
528 - $paymentMethodDetails = (new API())->makeRequest('payment_methods/' . $methodId, [], (new StripeSettingsBase())->getApiKey(), 'GET');
929 + $paymentMethodDetails = (new API())->makeRequest('payment_methods/' . $methodId, [], (new StripeSettingsBase())->getApiKey($mode), 'GET');
529 930
530 931 if (is_wp_error($paymentMethodDetails) || !$paymentMethodDetails) {
531 932 $billingInfo = PaymentHelper::parsePaymentMethodDetails('stripe', ['type' => 'card']);
532 933 } else {
@@ -634,8 +1035,14 @@
634 1035
635 1036 return (new StatusHelper($order))->syncOrderStatuses($transaction);
636 1037 }
637 1038
1039 + // Bail before the dispute round-trip below, which would otherwise annotate
1040 + // a row this confirmation is not allowed to touch.
1041 + if (in_array($transaction->status, $this->postPaymentStatuses(), true)) {
1042 + return (new StatusHelper($order))->syncOrderStatuses($transaction);
1043 + }
1044 +
638 1045 $chargeCurrency = Arr::get($charge, 'currency', $transaction->currency);
639 1046 $status = Arr::get($charge, 'status') === 'succeeded' ? Status::TRANSACTION_SUCCEEDED : Status::TRANSACTION_PENDING;
640 1047
641 1048 if ($status === Status::TRANSACTION_PENDING) {
@@ -668,9 +1075,9 @@
668 1075 $transactionUpdateData['transaction_type'] = Status::TRANSACTION_TYPE_DISPUTE;
669 1076 $disputeId = Arr::get($charge, 'dispute', '');
670 1077 $reason = 'unknown';
671 1078
672 - $retreiveDispute = (new API())->getStripeObject('disputes/' . $disputeId);
1079 + $retreiveDispute = (new API())->getStripeObject('disputes/' . $disputeId, [], StripeHelper::modeFromLivemode(Arr::isTrue($charge, 'livemode')));
673 1080
674 1081 if (!is_wp_error($retreiveDispute)) {
675 1082 $reason = Arr::get($retreiveDispute, 'reason');
676 1083 }
@@ -696,10 +1103,51 @@
696 1103 ]);
697 1104 }
698 1105 }
699 1106
1107 + // Stripe's charge `created` is when the money actually moved. When this
1108 + // confirmation is the first path to mark the transaction succeeded, it
1109 + // beats the model hook's fallback now() stamp — which for a delayed
1110 + // webhook would be the (later) processing time, not the charge time.
1111 + $chargeCreatedAt = (int)Arr::get($charge, 'created', 0);
1112 + if ($chargeCreatedAt && empty($transaction->meta['settled_at'])) {
1113 + $transaction->meta = array_merge($transaction->meta, [
1114 + 'settled_at' => DateTime::anyTimeToGmt($chargeCreatedAt)->format('Y-m-d H:i:s')
1115 + ]);
1116 + }
1117 +
700 1118 $transaction->fill($transactionUpdateData);
701 - $transaction->save();
1119 + $transaction->updated_at = DateTime::gmtNow();
1120 +
1121 + // The re-read at the top of this method is a check, not a claim, and the
1122 + // disputed branch above spends a remote round-trip inside the window it
1123 + // leaves open. Write through a guarded UPDATE so a refund landing there
1124 + // wins. `succeeded` and `authorized` stay writable: the first is
1125 + // idempotent here, the second is exactly what capture moves forward.
1126 + $dirty = $transaction->getDirty();
1127 +
1128 + if ($dirty) {
1129 + OrderTransaction::query()
1130 + ->where('id', $transaction->id)
1131 + ->whereNotIn('status', $this->postPaymentStatuses())
1132 + ->update($dirty);
1133 + }
1134 +
1135 + // Decide on the row, not on the affected-row count — an identical replay
1136 + // inside the same second changes nothing and still reports zero.
1137 + $confirmed = OrderTransaction::query()->find($transaction->id);
1138 +
1139 + if (!$confirmed) {
1140 + return $order;
1141 + }
1142 +
1143 + // Settled behind our back: sync the order and skip the confirmation side
1144 + // effects below — logs, subscription activation, vault persistence.
1145 + if (in_array($confirmed->status, $this->postPaymentStatuses(), true)) {
1146 + return (new StatusHelper($order))->syncOrderStatuses($confirmed);
1147 + }
1148 +
1149 + $transaction = $confirmed;
702 1150
703 1151 fluent_cart_add_log(__('Stripe Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from Stripe. Transaction ID:', 'fluent-cart') . ' ' . $intentId, 'info', [
704 1152 'module_name' => 'order',
705 1153 'module_id' => $order->id,