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 +496 -59 1.6.0 → 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')
@@ -346,42 +726,30 @@
346 726 'id' => Arr::get($method, 'id'),
347 727 'type' => $type,
348 728 ];
349 729
350 - $fingerprint = null;
351 - switch ($type) {
352 - case 'card':
353 - $pm['last4'] = Arr::get($method, 'card.last4');
354 - $pm['brand'] = Arr::get($method, 'card.brand');
355 - $pm['exp_month'] = Arr::get($method, 'card.exp_month');
356 - $pm['exp_year'] = Arr::get($method, 'card.exp_year');
357 - $pm['fingerprint'] = Arr::get($method, 'card.fingerprint');
358 - $fingerprint = $pm['fingerprint'];
730 + $details = Arr::get($method, $type);
731 + if (!is_array($details)) {
732 + $details = [];
733 + }
734 +
735 + foreach (['last4', 'brand', 'exp_month', 'exp_year', 'fingerprint'] as $field) {
736 + if (Arr::has($details, $field)) {
737 + $pm[$field] = Arr::get($details, $field);
738 + }
739 + }
740 +
741 + // Identifier for account-like methods: link.email, paypal.payer_email,
742 + // cashapp.cashtag — first one present labels the entry in the UI.
743 + foreach (['email', 'payer_email', 'cashtag'] as $field) {
744 + if (Arr::get($details, $field)) {
745 + $pm['email'] = Arr::get($details, $field);
359 746 break;
360 -// case 'sepa_debit':
361 -// $pm['last4'] = Arr::get($method, 'sepa_debit.last4');
362 -// $fingerprint = Arr::get($method, 'sepa_debit.fingerprint');
363 -// break;
364 -// case 'ach_debit':
365 -// $pm['last4'] = Arr::get($method, 'ach_debit.last4');
366 -// $fingerprint = Arr::get($method, 'ach_debit.fingerprint');
367 -// break;
368 -// case 'ach_credit_transfer':
369 -// $pm['account_number'] = Arr::get($method, 'ach_credit_transfer.account_number');
370 -// $fingerprint = Arr::get($method, 'ach_credit_transfer.fingerprint');
371 -// break;
372 -// case 'us_bank_account':
373 -// $pm['account_number'] = Arr::get($method, 'us_bank_account.account_number');
374 -// $fingerprint = Arr::get($method, 'us_bank_account.fingerprint');
375 -// break;
376 -// case 'bacs_debit':
377 -// $pm['account_number'] = Arr::get($method, 'bacs_debit.account_number');
378 -// $fingerprint = Arr::get($method, 'bacs_debit.fingerprint');
379 -// break;
380 - default:
381 - break;
747 + }
382 748 }
383 749
750 + $fingerprint = Arr::get($details, 'fingerprint');
751 +
384 752 if ($fingerprint && in_array($fingerprint, $seenFingerprints, true)) {
385 753 continue;
386 754 }
387 755 if ($fingerprint) {
@@ -396,20 +764,21 @@
396 764 }
397 765 }
398 766
399 767 $meta = $fctCustomer->getMeta($metaKey);
768 + if (!is_array($meta)) {
769 + $meta = [];
770 + }
400 771 $meta['stripe'] = $stripeMeta;
401 772
402 - $fctCustomer->updateMeta($metaKey, [
403 - 'stripe' => $stripeMeta
404 - ]);
773 + $fctCustomer->updateMeta($metaKey, $meta);
405 774 }
406 775
407 - public function confirmSetupIntent($setupIntent, $trxHash = null)
776 + public function confirmSetupIntent($setupIntent, $trxHash = null, $mode = 'current')
408 777 {
409 778 $api = new API();
410 779
411 - $response = $api->getStripeObject('setup_intents/' . $setupIntent);
780 + $response = $api->getStripeObject('setup_intents/' . $setupIntent, [], $mode);
412 781
413 782 if (is_wp_error($response)) {
414 783 return $response;
415 784 }
@@ -426,9 +795,30 @@
426 795 if ($trxHash !== null && $transaction->uuid !== $trxHash) {
427 796 return new \WP_Error('invalid_request', __('Invalid request.', 'fluent-cart'));
428 797 }
429 798
430 - 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 +
431 821 return new \WP_Error(
432 822 'setup_intent_not_succeeded',
433 823 __('Payment method setup is not complete. Please complete the payment method setup.', 'fluent-cart')
434 824 );
@@ -449,15 +839,15 @@
449 839
450 840 $paymentMethod = Arr::get($response, 'payment_method');
451 841 $customer = Arr::get($response, 'customer');
452 842
453 - $billingInfo = $this->getPaymentMethodDetails($paymentMethod);
843 + $billingInfo = $this->getPaymentMethodDetails($paymentMethod, $mode);
454 844
455 845 // attach the payment method to the customer
456 846 if ($paymentMethod && $customer) {
457 847 $api->createStripeObject('payment_methods/' . $paymentMethod . '/attach', [
458 848 'customer' => $customer
459 - ]);
849 + ], $mode);
460 850
461 851 $this->savePaymentMethodToCustomerMeta($customer, $paymentMethod, $order);
462 852 }
463 853
@@ -533,11 +923,11 @@
533 923 );
534 924 }
535 925 }
536 926
537 - public function getPaymentMethodDetails($methodId)
927 + public function getPaymentMethodDetails($methodId, $mode = 'current')
538 928 {
539 - $paymentMethodDetails = (new API())->makeRequest('payment_methods/' . $methodId, [], (new StripeSettingsBase())->getApiKey(), 'GET');
929 + $paymentMethodDetails = (new API())->makeRequest('payment_methods/' . $methodId, [], (new StripeSettingsBase())->getApiKey($mode), 'GET');
540 930
541 931 if (is_wp_error($paymentMethodDetails) || !$paymentMethodDetails) {
542 932 $billingInfo = PaymentHelper::parsePaymentMethodDetails('stripe', ['type' => 'card']);
543 933 } else {
@@ -645,8 +1035,14 @@
645 1035
646 1036 return (new StatusHelper($order))->syncOrderStatuses($transaction);
647 1037 }
648 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 +
649 1045 $chargeCurrency = Arr::get($charge, 'currency', $transaction->currency);
650 1046 $status = Arr::get($charge, 'status') === 'succeeded' ? Status::TRANSACTION_SUCCEEDED : Status::TRANSACTION_PENDING;
651 1047
652 1048 if ($status === Status::TRANSACTION_PENDING) {
@@ -679,9 +1075,9 @@
679 1075 $transactionUpdateData['transaction_type'] = Status::TRANSACTION_TYPE_DISPUTE;
680 1076 $disputeId = Arr::get($charge, 'dispute', '');
681 1077 $reason = 'unknown';
682 1078
683 - $retreiveDispute = (new API())->getStripeObject('disputes/' . $disputeId);
1079 + $retreiveDispute = (new API())->getStripeObject('disputes/' . $disputeId, [], StripeHelper::modeFromLivemode(Arr::isTrue($charge, 'livemode')));
684 1080
685 1081 if (!is_wp_error($retreiveDispute)) {
686 1082 $reason = Arr::get($retreiveDispute, 'reason');
687 1083 }
@@ -707,10 +1103,51 @@
707 1103 ]);
708 1104 }
709 1105 }
710 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 +
711 1118 $transaction->fill($transactionUpdateData);
712 - $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;
713 1150
714 1151 fluent_cart_add_log(__('Stripe Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from Stripe. Transaction ID:', 'fluent-cart') . ' ' . $intentId, 'info', [
715 1152 'module_name' => 'order',
716 1153 'module_id' => $order->id,