PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Modules / PaymentMethods / StripeGateway / Confirmations.php

Confirmations.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Modules/PaymentMethods/StripeGateway/Confirmations.php

1,242 lines 50.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\StripeGateway;
4
5 use FluentCart\App\App;
6 use FluentCart\App\Helpers\CurrenciesHelper;
7 use FluentCart\App\Helpers\Status;
8 use FluentCart\App\Helpers\StatusHelper;
9 use FluentCart\App\Models\Customer;
10 use FluentCart\App\Models\Order;
11 use FluentCart\App\Models\OrderTransaction;
12 use FluentCart\App\Models\Subscription;
13 use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API;
14 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
15 use FluentCart\App\Modules\Subscriptions\Services\SystemChargeService;
16 use FluentCart\App\Services\DateTime\DateTime;
17 use FluentCart\App\Services\Payments\PaymentHelper;
18 use FluentCart\Framework\Support\Arr;
19
20 class Confirmations
21 {
22 public function init()
23 {
24 add_action('wp_ajax_nopriv_fluent_cart_confirm_stripe_payment', [$this, 'confirmStripePayment']);
25 add_action('wp_ajax_fluent_cart_confirm_stripe_payment', [$this, 'confirmStripePayment']);
26
27 add_filter('fluent_cart/form_disable_stripe_connect', function ($value, $args) {
28 if (defined('FCT_STRIPE_LIVE_PUBLIC_KEY') || defined('FCT_STRIPE_TEST_PUBLIC_KEY')) {
29 return true;
30 }
31
32 return $value;
33 }, 10, 2);
34
35
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']);
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) {
66 // Get session ID from transaction meta
67 $sessionId = Arr::get($transaction->meta, 'session_id');
68
69 if ($sessionId) {
70 $this->confirmByCheckoutSession($sessionId, $transaction);
71 }
72 }
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;
85 }
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
403 private function confirmByCheckoutSession($sessionId, $transaction)
404 {
405
406 $api = new API();
407
408 $session = $api->getStripeObject('checkout/sessions/' . $sessionId, [
409 'expand' => ['payment_intent', 'subscription.latest_invoice.payment_intent.latest_charge']
410 ]);
411
412
413 if (is_wp_error($session)) {
414 fluent_cart_add_log(__('Stripe Session Retrieval Failed', 'fluent-cart'), $session->get_error_message(), 'error', [
415 'module_name' => 'order',
416 'module_id' => $transaction->order_id,
417 ]);
418 if ($transaction->subscription_id) {
419 $subscription = Subscription::query()->find($transaction->subscription_id);
420 if ($subscription) {
421 $subscription->addLog(__('Stripe Session Retrieval Failed', 'fluent-cart'), $session->get_error_message(), 'error');
422 }
423 }
424 return;
425 }
426
427 $paymentStatus = Arr::get($session, 'payment_status');
428 $mode = Arr::get($session, 'mode');
429
430 if ($mode === 'subscription') {
431 $vendorSubscription = Arr::get($session, 'subscription');
432 $vendorSubscriptionId = is_array($vendorSubscription) ? Arr::get($vendorSubscription, 'id') : $vendorSubscription;
433
434 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
435
436 if ($subscription && $vendorSubscriptionId) {
437 $updateData = [
438 'vendor_subscription_id' => $vendorSubscriptionId,
439 'vendor_customer_id' => Arr::get($vendorSubscription, 'customer'),
440 ];
441
442
443 if (is_array($vendorSubscription)) {
444 if (Arr::get($vendorSubscription, 'current_period_end')) {
445 $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', (int) Arr::get($vendorSubscription, 'current_period_end'));
446 }
447
448 if (Arr::get($vendorSubscription, 'trial_end')) {
449 $updateData['trial_ends_at'] = gmdate('Y-m-d H:i:s', (int) Arr::get($vendorSubscription, 'trial_end'));
450 }
451 }
452
453 $subscription->update($updateData);
454 }
455
456 $paymentIntent = null;
457 $billingInfo = [];
458
459
460 if (is_array($vendorSubscription)) {
461 $paymentIntent = Arr::get($vendorSubscription, 'latest_invoice.payment_intent');
462 }
463
464
465 if (!$paymentIntent && Arr::get($session, 'invoice')) {
466 $invoiceId = Arr::get($session, 'invoice');
467 $invoice = $api->getStripeObject('invoices/' . $invoiceId, [
468 'expand' => ['payment_intent.latest_charge']
469 ]);
470 if (!is_wp_error($invoice)) {
471 $paymentIntent = Arr::get($invoice, 'payment_intent.latest_charge');
472 }
473 }
474
475 if (!is_array($paymentIntent)) {
476 $paymentIntent = $api->getStripeObject('payment_intents/' . $paymentIntent, [
477 'expand' => ['latest_charge']
478 ]);
479 }
480
481
482 $charge = Arr::get($paymentIntent, 'latest_charge', []);
483
484 if ($charge) {
485 $billingInfo = $this->extractBillingInfoFromCharge($charge);
486 $this->processPaymentIntentConfirmation($paymentIntent, $transaction);
487 } else {
488 if ($paymentStatus === 'paid' || $transaction->total <= 0) {
489 // Try to get payment method from setup intent
490 $setupIntent = Arr::get($session, 'setup_intent');
491 if ($setupIntent) {
492 $setupIntentData = $api->getStripeObject('setup_intents/' . $setupIntent);
493 if (!is_wp_error($setupIntentData)) {
494 $paymentMethodId = Arr::get($setupIntentData, 'payment_method');
495 if ($paymentMethodId) {
496 $billingInfo = $this->getPaymentMethodDetails($paymentMethodId);
497 }
498 }
499 }
500
501 $transaction->status = Status::TRANSACTION_SUCCEEDED;
502 $transaction->save();
503 }
504 }
505
506 if ($subscription && !in_array($subscription->status, Status::getValidableSubscriptionStatuses())) {
507 (new SubscriptionsManager())->confirmSubscriptionAfterChargeSucceeded($subscription, $billingInfo);
508 }
509
510 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
511
512 } elseif ($mode === 'setup') {
513 // Zero-payable system-subscription hosted checkout — no payment_intent
514 // to confirm, just the vaulted setup_intent. confirmSetupIntent() also
515 // resolves the transaction by vendor_charge_id, so a stale/mismatched
516 // session for this transaction is harmless here.
517 $setupIntentId = Arr::get($session, 'setup_intent');
518 if ($setupIntentId) {
519 $this->confirmSetupIntent($setupIntentId);
520 }
521 } else {
522 if ($paymentStatus === 'paid') {
523 $paymentIntent = Arr::get($session, 'payment_intent');
524 if (is_array($paymentIntent)) {
525 $paymentIntent = $api->getStripeObject('payment_intents/' . $paymentIntent['id'], [
526 'expand' => ['latest_charge']
527 ]);
528 $this->processPaymentIntentConfirmation($paymentIntent, $transaction);
529 } elseif ($paymentIntent) {
530 $intentData = $api->getStripeObject('payment_intents/' . $paymentIntent, [
531 'expand' => ['latest_charge']
532 ]);
533 if (!is_wp_error($intentData)) {
534 $this->processPaymentIntentConfirmation($intentData, $transaction);
535 }
536 }
537 }
538 }
539 }
540
541
542 /**
543 * Process payment intent confirmation
544 */
545 private function processPaymentIntentConfirmation($intent, $transaction)
546 {
547 $charge = Arr::get($intent, 'latest_charge', []);
548 $intentId = Arr::get($intent, 'id');
549
550 if ($charge && $intentId) {
551 $this->confirmPaymentSuccessByCharge($transaction, [
552 'charge' => $charge,
553 'intent_id' => $intentId
554 ]);
555 }
556 }
557
558 /**
559 * Extract billing info from charge for subscription confirmation
560 */
561 private function extractBillingInfoFromCharge($charge)
562 {
563 $billingDetails = Arr::get($charge, 'billing_details', []);
564 $paymentMethodDetails = Arr::get($charge, 'payment_method_details', []);
565
566 return [
567 'method' => 'stripe',
568 'vendor_method_id' => Arr::get($charge, 'payment_method', ''),
569 'payment_type' => Arr::get($paymentMethodDetails, 'type'),
570 'details' => array_filter([
571 'brand' => Arr::get($paymentMethodDetails, 'card.brand'),
572 'last_4' => Arr::get($paymentMethodDetails, 'card.last4'),
573 'exp_month' => Arr::get($paymentMethodDetails, 'card.exp_month'),
574 'exp_year' => Arr::get($paymentMethodDetails, 'card.exp_year'),
575 'country' => Arr::get($paymentMethodDetails, 'card.country'),
576 'postal_code' => Arr::get($billingDetails, 'address.postal_code', ''),
577 'name' => Arr::get($billingDetails, 'name', '')
578 ])
579 ];
580 }
581
582 /*
583 * Only for validating hosted checkout payment confirmation
584 */
585 public function confirmStripePayment()
586 {
587 $intentId = App::request()->get('intentId');
588 if (empty($intentId)) {
589 wp_send_json(
590 [
591 'message' => __('Intent ID is required to confirm the payment.', 'fluent-cart'),
592 ],
593 400
594 );
595 }
596
597 $intentId = sanitize_text_field($intentId);
598
599 // in case of plan change, and first payment is 0, then setup intent will be created
600 if (strpos($intentId, 'seti_') === 0) {
601 $trxHash = sanitize_text_field(App::request()->get('trx_hash'));
602 if (empty($trxHash)) {
603 wp_send_json(['message' => __('Invalid request.', 'fluent-cart')], 400);
604 }
605 $result = $this->confirmSetupIntent($intentId, $trxHash);
606 if (is_wp_error($result)) {
607 wp_send_json(
608 [
609 'message' => $result->get_error_message(),
610 'transaction_status' => $this->reportedTransactionStatus(
611 OrderTransaction::query()->where('uuid', $trxHash)->first()
612 ),
613 ], 400
614 );
615 }
616 wp_send_json(
617 [
618 'message' => __('Setup intent confirmed successfully. Please check your subscriptions.', 'fluent-cart'),
619 ], 200
620 );
621 }
622
623 $api = new API();
624 $response = $api->getStripeObject('payment_intents/' . $intentId, [
625 'expand' => ['latest_charge']
626 ]);
627
628 if (is_wp_error($response)) {
629 wp_send_json(
630 [
631 'message' => $response->get_error_message(),
632 ],
633 500
634 );
635 }
636
637 $transaction = OrderTransaction::query()->where('vendor_charge_id', $intentId)->first();
638
639 if (!$transaction) {
640 wp_send_json(
641 [
642 'message' => __('Order not found for the provided intent ID.', 'fluent-cart'),
643 ],
644 404
645 );
646 }
647
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;
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
681 wp_send_json(
682 [
683 'redirect_url' => $transaction->getSuccessUrl(),
684 'order' => [
685 'uuid' => $transaction->order->uuid,
686 ],
687 'message' => __('Payment confirmed successfully. Redirecting...!', 'fluent-cart')
688 ], 200
689 );
690 }
691
692 // make sure customer given the acknowledgement for saving the payment methods
693 public function savePaymentMethodToCustomerMeta($vendorCustomer, $paymentMethodId, $order)
694 {
695 $fctCustomer = Customer::query()->where('id', $order->customer_id)->first();
696 $metaKey = 'saved_payment_method';
697
698 $stripeApiKey = (new StripeSettingsBase())->getApiKey();
699 $api = new API();
700
701 // Allow redisplay for the payment method
702 $api->makeRequest('payment_methods/' . $paymentMethodId, ['allow_redisplay' => 'always'], $stripeApiKey, 'POST');
703
704 // Fetch customer to get default payment method
705 $customer = $api->makeRequest('customers/' . $vendorCustomer, [], $stripeApiKey, 'GET');
706 $defaultPaymentMethodId = Arr::get($customer, 'invoice_settings.default_payment_method');
707
708 $paymentMethodsResponse = $api->makeRequest(
709 'customers/' . $vendorCustomer . '/payment_methods',
710 [],
711 $stripeApiKey,
712 'GET'
713 );
714
715 $stripeMeta = [
716 'customer_id' => $vendorCustomer,
717 'payment_methods' => []
718 ];
719
720 if ($paymentMethodsResponse && !is_wp_error($paymentMethodsResponse) && ($methods = Arr::get($paymentMethodsResponse, 'data', []))) {
721 $seenFingerprints = [];
722 foreach ($methods as $method) {
723
724 $type = Arr::get($method, 'type');
725 $pm = [
726 'id' => Arr::get($method, 'id'),
727 'type' => $type,
728 ];
729
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);
746 break;
747 }
748 }
749
750 $fingerprint = Arr::get($details, 'fingerprint');
751
752 if ($fingerprint && in_array($fingerprint, $seenFingerprints, true)) {
753 continue;
754 }
755 if ($fingerprint) {
756 $seenFingerprints[] = $fingerprint;
757 }
758
759 if ($defaultPaymentMethodId === Arr::get($method, 'id')) {
760 $stripeMeta['payment_methods']['default'] = $pm;
761 } else {
762 $stripeMeta['payment_methods'][] = $pm;
763 }
764 }
765 }
766
767 $meta = $fctCustomer->getMeta($metaKey);
768 if (!is_array($meta)) {
769 $meta = [];
770 }
771 $meta['stripe'] = $stripeMeta;
772
773 $fctCustomer->updateMeta($metaKey, $meta);
774 }
775
776 public function confirmSetupIntent($setupIntent, $trxHash = null)
777 {
778 $api = new API();
779
780 $response = $api->getStripeObject('setup_intents/' . $setupIntent);
781
782 if (is_wp_error($response)) {
783 return $response;
784 }
785
786 $transaction = OrderTransaction::query()->where('vendor_charge_id', $setupIntent)->first();
787
788 if (!$transaction) {
789 return new \WP_Error(
790 'transaction_not_found',
791 __('Transaction not found for the provided setup intent.', 'fluent-cart')
792 );
793 }
794
795 if ($trxHash !== null && $transaction->uuid !== $trxHash) {
796 return new \WP_Error('invalid_request', __('Invalid request.', 'fluent-cart'));
797 }
798
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
821 return new \WP_Error(
822 'setup_intent_not_succeeded',
823 __('Payment method setup is not complete. Please complete the payment method setup.', 'fluent-cart')
824 );
825 }
826
827 $transaction->status = Status::TRANSACTION_PENDING;
828
829 if ($transaction->total <= 0) {
830 $transaction->status = Status::TRANSACTION_SUCCEEDED;
831 }
832
833
834 $transaction->vendor_charge_id = ''; // removing vendor charge id , because setup intent id is not the charge id
835 $transaction->save();
836
837 $order = Order::query()->where('id', $transaction->order_id)->first();
838
839
840 $paymentMethod = Arr::get($response, 'payment_method');
841 $customer = Arr::get($response, 'customer');
842
843 $billingInfo = $this->getPaymentMethodDetails($paymentMethod);
844
845 // attach the payment method to the customer
846 if ($paymentMethod && $customer) {
847 $api->createStripeObject('payment_methods/' . $paymentMethod . '/attach', [
848 'customer' => $customer
849 ]);
850
851 $this->savePaymentMethodToCustomerMeta($customer, $paymentMethod, $order);
852 }
853
854
855 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
856
857 if ($subscription) {
858 if ($subscription->isSystem()) {
859 // Zero-payable free-trial checkout: no vendor subscription to confirm,
860 // just vault the Stripe customer + reusable payment method.
861 $stripeCustomerId = Arr::get($response, 'customer', '');
862 if ($stripeCustomerId && !$subscription->vendor_customer_id) {
863 $subscription->vendor_customer_id = $stripeCustomerId;
864 $subscription->save();
865 }
866
867 $vendorMethodId = Arr::get($response, 'payment_method', '');
868 if ($vendorMethodId) {
869 $billingInfo['vendor_method_id'] = $vendorMethodId;
870 }
871
872 $this->maybePersistSystemVaultToken($subscription, $order, $billingInfo);
873 } else {
874 (new SubscriptionsManager())->confirmSubscriptionAfterChargeSucceeded($subscription, $billingInfo);
875 }
876 }
877
878 (new StatusHelper($order))->syncOrderStatuses($transaction);
879
880 // Notify that a renewal invoice has been deferred — the actual charge will fire
881 // later via the gateway's subscription_cycle webhook. Gateways that capture a card
882 // for a deferred renewal charge should fire this so the invoice status can be updated.
883 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
884 do_action('fluent_cart/renewal/payment_scheduled', [
885 'order' => $order,
886 'subscription' => $subscription,
887 ]);
888 }
889
890 }
891
892 /**
893 * Vault the token for a system subscription, or demote to manual when the
894 * initial checkout capture came back without one — mirrors PayPal's
895 * Processor::maybePersistVaultToken().
896 */
897 private function maybePersistSystemVaultToken($subscription, $order, $billingInfo)
898 {
899 $vendorMethodId = Arr::get($billingInfo, 'vendor_method_id', '');
900 $existing = $subscription->getMeta('active_payment_method', []) ?: [];
901 // Meta has two shapes in the wild: vendor_method_id (confirmation paths) and
902 // details.payment_method_id (card-update flow) — accept both, same as chargeRenewal().
903 $existingMethodId = Arr::get($existing, 'vendor_method_id') ?: Arr::get($existing, 'details.payment_method_id');
904
905 if ($vendorMethodId) {
906 if ($existingMethodId === $vendorMethodId) {
907 return; // already persisted (webhook/AJAX race)
908 }
909
910 $subscription->updateMeta('active_payment_method', $billingInfo);
911 return;
912 }
913
914 // No token on the initial capture and none stored yet — never leave a
915 // system subscription that can never be charged.
916 if ($order
917 && $order->type === Status::ORDER_TYPE_SUBSCRIPTION
918 && !$existingMethodId
919 ) {
920 SystemChargeService::demoteToManual(
921 $subscription,
922 __('Stripe did not return a saved payment method for automatic charging.', 'fluent-cart')
923 );
924 }
925 }
926
927 public function getPaymentMethodDetails($methodId)
928 {
929 $paymentMethodDetails = (new API())->makeRequest('payment_methods/' . $methodId, [], (new StripeSettingsBase())->getApiKey(), 'GET');
930
931 if (is_wp_error($paymentMethodDetails) || !$paymentMethodDetails) {
932 $billingInfo = PaymentHelper::parsePaymentMethodDetails('stripe', ['type' => 'card']);
933 } else {
934 $billingInfo = PaymentHelper::parsePaymentMethodDetails('stripe', $paymentMethodDetails);
935 }
936
937 return $billingInfo;
938 }
939
940
941 public function syncRemoteTransaction(OrderTransaction $transaction)
942 {
943 $mode = $transaction->payment_mode;
944 if (!$mode) {
945 $mode = $transaction->order ? $transaction->order->mode : '';
946 }
947
948 $intent = (new API())->getStripeObject('payment_intents/' . $transaction->vendor_charge_id, [
949 'expand' => ['latest_charge']
950 ], $mode);
951
952 if (is_wp_error($intent)) {
953 return $intent;
954 }
955
956 $intentStatus = Arr::get($intent, 'status');
957
958 if ($intentStatus === 'succeeded') {
959 $chargeCurrency = strtoupper((string) Arr::get($intent, 'latest_charge.currency', ''));
960 if ($chargeCurrency && $transaction->currency && strtoupper($transaction->currency) !== $chargeCurrency) {
961 fluent_cart_warning_log(
962 __('Stripe Currency Mismatch On Sync', 'fluent-cart'),
963 sprintf(
964 /* translators: %1$s: expected currency, %2$s: received currency */
965 __('Charge currency mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
966 $transaction->currency,
967 $chargeCurrency
968 ),
969 [
970 'module_name' => 'order',
971 'module_id' => $transaction->order_id,
972 'log_type' => 'api'
973 ]
974 );
975
976 return new \WP_Error('currency_mismatch', __('The Stripe payment currency does not match this transaction. Please verify the payment at Stripe.', 'fluent-cart'));
977 }
978
979 $this->confirmPaymentSuccessByCharge($transaction, [
980 'charge' => Arr::get($intent, 'latest_charge', []),
981 'intent_id' => Arr::get($intent, 'id'),
982 ]);
983
984 return OrderTransaction::query()->find($transaction->id);
985 }
986
987 if ($intentStatus === 'processing') {
988 return new \WP_Error('still_processing', __('The payment is still processing at Stripe. Please try again later.', 'fluent-cart'));
989 }
990
991 $failureMessage = Arr::get($intent, 'last_payment_error.message');
992 if (!$failureMessage) {
993 $failureMessage = sprintf(
994 /* translators: %1$s: Stripe payment intent status */
995 __('The payment has not completed at Stripe (status: %1$s).', 'fluent-cart'),
996 $intentStatus ?: 'unknown'
997 );
998 }
999
1000 return new \WP_Error('charge_not_completed', $failureMessage);
1001 }
1002
1003 /**
1004 * Confirm payment success by charge.
1005 * Currently used by:
1006 * - fluent_cart/payments/stripe/webhook_charge_succeeded
1007 * -
1008 *
1009 * @param OrderTransaction $transaction
1010 * @param array $args
1011 * @param array $args ['charge'] - The charge details from Stripe.
1012 * @param string $args ['intent_id'] - The intent ID from Stripe.
1013 */
1014 public function confirmPaymentSuccessByCharge(OrderTransaction $transaction, $args = [])
1015 {
1016 $charge = Arr::get($args, 'charge', []);
1017 $intentId = Arr::get($args, 'intent_id', '');
1018
1019 if (!$intentId) {
1020 $intentId = Arr::get($charge, 'payment_intent', '');
1021 }
1022
1023 $order = Order::query()->where('id', $transaction->order_id)->first();
1024
1025 // in race conditions between webhook and AJAX confirmation
1026 $transaction = OrderTransaction::query()->where('id', $transaction->id)->first();
1027 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
1028 if ($transaction->subscription_id) {
1029 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
1030 // Only automatic subs have a remote to resync; store-managed (system/manual) have none.
1031 if ($subscription && $subscription->vendor_subscription_id) {
1032 $subscription->reSyncFromRemote();
1033 }
1034 }
1035
1036 return (new StatusHelper($order))->syncOrderStatuses($transaction);
1037 }
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
1045 $chargeCurrency = Arr::get($charge, 'currency', $transaction->currency);
1046 $status = Arr::get($charge, 'status') === 'succeeded' ? Status::TRANSACTION_SUCCEEDED : Status::TRANSACTION_PENDING;
1047
1048 if ($status === Status::TRANSACTION_PENDING) {
1049 if (!$transaction->vendor_charge_id && !empty($intentId)) {
1050 $transaction->update(['vendor_charge_id' => $intentId]);
1051 }
1052 return $order; // already pending,
1053 }
1054
1055 $normalizedAmount = (int)Arr::get($charge, 'amount', 0);
1056
1057 if ($chargeCurrency && CurrenciesHelper::isZeroDecimal($chargeCurrency)) {
1058 $normalizedAmount = $normalizedAmount * 100;
1059 }
1060
1061 $transactionUpdateData = array_filter([
1062 'order_id' => $order->id,
1063 'total' => $normalizedAmount,
1064 'currency' => $chargeCurrency,
1065 'status' => $status,
1066 'payment_method' => 'stripe',
1067 'card_last_4' => Arr::get($charge, 'payment_method_details.card.last4', ''),
1068 'card_brand' => Arr::get($charge, 'payment_method_details.card.brand', ''),
1069 'payment_method_type' => Arr::get($charge, 'payment_method_details.type', ''),
1070 'vendor_charge_id' => $intentId,
1071 'payment_mode' => Arr::isTrue($charge, 'livemode') ? 'live' : 'test'
1072 ]);
1073
1074 if (Arr::get($charge, 'disputed', false)) {
1075 $transactionUpdateData['transaction_type'] = Status::TRANSACTION_TYPE_DISPUTE;
1076 $disputeId = Arr::get($charge, 'dispute', '');
1077 $reason = 'unknown';
1078
1079 $retreiveDispute = (new API())->getStripeObject('disputes/' . $disputeId);
1080
1081 if (!is_wp_error($retreiveDispute)) {
1082 $reason = Arr::get($retreiveDispute, 'reason');
1083 }
1084
1085 $transaction->meta = array_merge($transaction->meta, [
1086 'dispute_id' => $disputeId,
1087 'dispute_reason' => $reason,
1088 'is_dispute_actionable' => in_array(Arr::get($retreiveDispute, 'status'), ['needs_response']),
1089 'is_charge_refundable' => Arr::get($retreiveDispute, 'is_charge_refundable', false)
1090 ]);
1091
1092 fluent_cart_warning_log('Stripe charge disputed', 'This payment was disputed (' . $charge['id'] . ')', [
1093 'module_name' => 'order',
1094 'module_id' => $order->id,
1095 'log_type' => 'api'
1096 ]);
1097 if ($transaction->subscription_id) {
1098 fluent_cart_warning_log('Stripe charge disputed', 'This payment was disputed (' . $charge['id'] . ')', [
1099 'module_type' => 'FluentCart\App\Models\Subscription',
1100 'module_id' => $transaction->subscription_id,
1101 'module_name' => 'subscription',
1102 'log_type' => 'api'
1103 ]);
1104 }
1105 }
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
1118 $transaction->fill($transactionUpdateData);
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;
1150
1151 fluent_cart_add_log(__('Stripe Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from Stripe. Transaction ID:', 'fluent-cart') . ' ' . $intentId, 'info', [
1152 'module_name' => 'order',
1153 'module_id' => $order->id,
1154 ]);
1155 if ($transaction->subscription_id) {
1156 fluent_cart_add_log(__('Stripe Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from Stripe. Transaction ID:', 'fluent-cart') . ' ' . $intentId, 'info', [
1157 'module_type' => 'FluentCart\App\Models\Subscription',
1158 'module_id' => $transaction->subscription_id,
1159 'module_name' => 'subscription',
1160 ]);
1161 }
1162
1163 $billingDetails = Arr::get($charge, 'billing_details', []);
1164 $paymentMethodDetails = Arr::get($charge, 'payment_method_details', []);
1165 $billingInfo = [
1166 'method' => 'stripe',
1167 'vendor_method_id' => Arr::get($charge, 'payment_method', ''),
1168 'payment_type' => Arr::get($paymentMethodDetails, 'type'),
1169 'details' => array_filter([
1170 'brand' => Arr::get($paymentMethodDetails, 'card.brand'),
1171 'last_4' => Arr::get($paymentMethodDetails, 'card.last4'),
1172 'exp_month' => Arr::get($paymentMethodDetails, 'card.exp_month'),
1173 'exp_year' => Arr::get($paymentMethodDetails, 'card.exp_year'),
1174 'country' => Arr::get($paymentMethodDetails, 'card.country'),
1175 'postal_code' => Arr::get($billingDetails, 'address.postal_code', ''),
1176 'name' => Arr::get($billingDetails, 'name', '')
1177 ])
1178 ];
1179
1180 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
1181
1182 $parentOrderId = $transaction->order->parent_id;
1183 if (!$parentOrderId) {
1184 return;
1185 }
1186 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
1187
1188 if (!$subscription) {
1189 return $order; // No subscription found for this renewal order. Something is wrong.
1190 }
1191
1192 $subscriptionArgs = [
1193 'status' => Status::SUBSCRIPTION_ACTIVE,
1194 'canceled_at' => null,
1195 'current_payment_method' => 'stripe'
1196 ];
1197
1198 // Only automatic subs expose a Stripe subscription to read the period end from;
1199 // store-managed (system/manual) advance next_billing_date via handleRenewalPaid.
1200 if ($subscription->vendor_subscription_id) {
1201 $response = (new API())->getStripeObject('subscriptions/' . $subscription->vendor_subscription_id, [], $transaction->payment_mode);
1202 if (!is_wp_error($response)) {
1203 $nextBillingDate = Arr::get($response, 'current_period_end') ?? null;
1204 if ($nextBillingDate) {
1205 $subscriptionArgs['next_billing_date'] = gmdate('Y-m-d H:i:s', (int)$nextBillingDate);
1206 }
1207 }
1208 }
1209
1210 SubscriptionService::recordManualRenewal($subscription, $transaction, [
1211 'billing_info' => $billingInfo,
1212 'subscription_args' => $subscriptionArgs
1213 ]);
1214
1215 } else {
1216 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
1217
1218 if ($subscription && !in_array($subscription->status, Status::getValidableSubscriptionStatuses())) {
1219 (new SubscriptionsManager())->confirmSubscriptionAfterChargeSucceeded($subscription, $billingInfo);
1220 }
1221
1222 // System (auto-charged, store-billed) subscription: persist the token from
1223 // the first charge — the only write path for it, since
1224 // confirmSubscriptionAfterChargeSucceeded() early-returns without a vendor subscription.
1225 if ($subscription && $subscription->isSystem()) {
1226 $stripeCustomerId = Arr::get($charge, 'customer', '');
1227 if ($stripeCustomerId && !$subscription->vendor_customer_id) {
1228 $subscription->vendor_customer_id = $stripeCustomerId;
1229 $subscription->save();
1230 }
1231
1232 $this->maybePersistSystemVaultToken($subscription, $order, $billingInfo);
1233 }
1234
1235 (new StatusHelper($order))->syncOrderStatuses($transaction);
1236 }
1237
1238 return $order;
1239 }
1240
1241 }
1242