PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / trunk
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder vtrunk
6.2.15 6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 All 197 releases
← All changes | app/Modules/Payments/PaymentMethods/Stripe/StripeInlineProcessor.php +134 -23 6.2.12 → trunk View file →
@@ -2,8 +2,9 @@
2 2
3 3 namespace FluentForm\App\Modules\Payments\PaymentMethods\Stripe;
4 4
5 5 use FluentForm\App\Helpers\Helper;
6 +use FluentForm\App\Models\Submission;
6 7 use FluentForm\App\Modules\Payments\PaymentHelper;
7 8 use FluentForm\Framework\Helpers\ArrayHelper;
8 9 use FluentForm\App\Modules\Payments\PaymentMethods\Stripe\API\SCA;
9 10 use FluentForm\App\Modules\Payments\PaymentMethods\Stripe\API\Plan;
@@ -106,9 +107,9 @@
106 107
107 108 $subscriptionTransactionArgs = Plan::getPriceIdsFromSubscriptionTransaction($subscription, $transaction);
108 109
109 110 if (is_wp_error($subscriptionTransactionArgs)) {
110 - $this->handlePaymentChargeError($customer->get_error_message(), $submission, $transaction, false, 'customer');
111 + $this->handlePaymentChargeError($subscriptionTransactionArgs->get_error_message(), $submission, $transaction, false, 'customer');
111 112 }
112 113
113 114 $subscriptionArgs = [
114 115 'customer' => $customer->id,
@@ -248,10 +249,12 @@
248 249 'errors' => __('Stripe Error: Payment Failed! Please try again.', 'fluentform'),
249 250 ], 423);
250 251 }
251 252
252 - // Submission status as paid
253 - $this->changeSubmissionPaymentStatus('paid');
253 + // was: an unconditional write; a refund recorded after the guard's read was overwritten with paid
254 + if (!$this->changeSubmissionPaymentStatusUnlessReversed('paid')) {
255 + $this->refuseIfReversedMeanwhile($submission, null, null);
256 + }
254 257
255 258 $subscriptions = $this->getSubscriptions();
256 259
257 260 $this->processSubscriptionSuccess($subscriptions, $invoice, $submission);
@@ -257,10 +260,13 @@
257 260 $this->processSubscriptionSuccess($subscriptions, $invoice, $submission);
258 261
259 262 $transaction = $this->getLastTransaction($submission->id);
260 263
261 - $paymentStatus = $this->getIntentSuccessName($invoice->payment_intent);
262 - $this->processOnetimeSuccess($invoice, $transaction, $paymentStatus);
264 + // was: intent only; a $0 invoice has none, so the invoice decides
265 + $paymentStatus = $this->getIntentSuccessName($invoice->payment_intent, $invoice);
266 + if (!$this->processOnetimeSuccess($invoice, $transaction, $paymentStatus)) {
267 + $this->refuseIfReversedMeanwhile($submission, $transaction, null);
268 + }
263 269
264 270 $this->recalculatePaidTotal();
265 271
266 272 $this->sendSuccess($submission);
@@ -282,9 +288,9 @@
282 288
283 289 $intent = SCA::createPaymentIntent($intentArgs, $this->form->id);
284 290
285 291 if (is_wp_error($intent)) {
286 - $this->handlePaymentChargeError($intent->get_error_message(), $submission, $transaction, false, 'payment_intent');
292 + $this->handlePaymentChargeError($intent->get_error_message(), $submission, $transaction, false, 'payment_intent', $intent);
287 293 }
288 294
289 295 if (
290 296 $intent->status == 'requires_action' &&
@@ -342,9 +348,12 @@
342 348 }
343 349
344 350 $this->updateTransaction($transaction->id, $transactionData);
345 351
346 - $this->changeTransactionStatus($transaction->id, 'paid');
352 + // was: an unconditional write; a refund recorded after the guard's read was overwritten with paid
353 + if (!$this->changeTransactionStatusUnlessReversed($transaction->id, 'paid')) {
354 + $this->refuseIfReversedMeanwhile($submission, $transaction, null);
355 + }
347 356
348 357 $logData = [
349 358 'parent_source_id' => $submission->form_id,
350 359 'source_type' => 'submission_item',
@@ -362,9 +371,11 @@
362 371 ]);
363 372
364 373 // Trigger fluentform/after_payment_status_change (via BaseProcessor),
365 374 // consistent with hosted Stripe checkout and offline payment flows.
366 - $this->changeSubmissionPaymentStatus('paid');
375 + if (!$this->changeSubmissionPaymentStatusUnlessReversed('paid')) {
376 + $this->refuseIfReversedMeanwhile($submission, $transaction, null);
377 + }
367 378
368 379 $logData = [
369 380 'parent_source_id' => $submission->form_id,
370 381 'source_type' => 'submission_item',
@@ -421,32 +432,37 @@
421 432 if (!$submission || !$submission->id) {
422 433 return new \WP_Error('invalid_submission', __('Invalid submission.', 'fluentform'));
423 434 }
424 435
425 - if ($submission->payment_status === 'paid') {
436 + // was: rejected a submission already 'paid'; that is now the recovery case, a reversed one is what must never be confirmed
437 + if (PaymentHelper::isReversedPaymentStatus($submission->payment_status)) {
426 438 return new \WP_Error(
427 - 'already_paid',
428 - __('This payment has already been completed and cannot be modified.', 'fluentform')
439 + 'payment_reversed',
440 + __('This payment has been reversed and cannot be confirmed.', 'fluentform')
429 441 );
430 442 }
431 443
432 - // Transaction must exist and be in 'intended' status (set by processScaBeforeVerification
433 - // when the SCA flow starts). A 'pending' transaction means SCA was never initiated,
434 - // 'paid'/'failed' means it's already been processed.
435 444 if (!$transaction) {
436 445 return new \WP_Error('no_transaction', __('No transaction found for this submission.', 'fluentform'));
437 446 }
438 447
439 - if ($transaction->status !== 'intended') {
448 + // 'intended' is written when the 3DS challenge starts. If the charge.succeeded webhook
449 + // lands before the browser returns, this row is already 'paid' (or 'processing') while
450 + // the submission and subscription are still unfinished; the browser must complete them.
451 + $isAwaitingBrowserConfirmation = 'intended' === $transaction->status;
452 + $wasSettledByWebhookBeforeBrowserReturned = in_array($transaction->status, ['processing', 'paid'], true);
453 +
454 + if (!$isAwaitingBrowserConfirmation && !$wasSettledByWebhookBeforeBrowserReturned) {
440 455 return new \WP_Error(
441 456 'invalid_transaction_status',
442 - __('This transaction is not awaiting payment confirmation.', 'fluentform')
457 + __('This transaction is not an active payment attempt and cannot be confirmed.', 'fluentform')
443 458 );
444 459 }
445 460
446 461 // Verify the payment intent ID matches what was stored during SCA initiation.
447 462 // processScaBeforeVerification() stores the intent as charge_id.
448 - if ($transaction->charge_id && $transaction->charge_id !== $paymentIntentId) {
463 + // was: skipped when charge_id was empty; the intent binding is the identity check, so it is required
464 + if (!$transaction->charge_id || $transaction->charge_id !== $paymentIntentId) {
449 465 return new \WP_Error(
450 466 'payment_intent_mismatch',
451 467 __('Payment verification failed. Payment intent does not match.', 'fluentform')
452 468 );
@@ -491,23 +507,42 @@
491 507 'errors' => $validation->get_error_message(),
492 508 ], 423);
493 509 }
494 510
511 + // was: re-ran the status writers, so a repeated callback fired the payment-status hooks again
512 + if ($this->isPaymentAlreadyCompleted($submission, $transaction)) {
513 + $this->sendSuccess($submission);
514 + }
515 +
495 516 // Use submission's form_id rather than trusting $_REQUEST
496 517 $formId = $submission->form_id;
497 518
498 - $confirmation = SCA::confirmPayment($paymentIntentId, [
499 - 'payment_method' => $paymentMethod,
500 - ], $formId);
519 + // was: confirmed blindly; the webhook may already have settled this intent, and a Stripe outage marked the payment failed
520 + $confirmation = SCA::retrievePaymentIntent($paymentIntentId, [], $formId);
501 521
502 522 if (is_wp_error($confirmation)) {
503 - $message = 'Payment has been failed. ' . $confirmation->get_error_message();
504 - $this->handlePaymentChargeError($message, $submission, $transaction, $confirmation, 'payment_error');
523 + $this->sendRetryableVerificationError($submission, $confirmation);
505 524 }
506 525
526 + // Confirming an intent Stripe has already settled counts against its confirm limit.
527 + if ('requires_confirmation' === $confirmation->status) {
528 + $confirmation = SCA::confirmPayment($paymentIntentId, [
529 + 'payment_method' => $paymentMethod,
530 + ], $formId);
531 +
532 + if (is_wp_error($confirmation)) {
533 + $message = 'Payment has been failed. ' . $confirmation->get_error_message();
534 + $this->handlePaymentChargeError($message, $submission, $transaction, $confirmation, 'payment_error');
535 + }
536 + }
537 +
507 538 if ($confirmation->status == 'succeeded') {
508 539 $charge = $confirmation->charges->data[0];
509 540
541 + // was: settled from the objects read before the Stripe round-trip; a refund recorded meanwhile,
542 + // or one Stripe already reports on the charge, was overwritten with paid and fulfilled
543 + $this->refuseIfReversedMeanwhile($submission, $transaction, $charge);
544 +
510 545 $confirmedCurrency = strtolower((string) $confirmation->currency);
511 546 $transactionCurrency = strtolower((string) $transaction->currency);
512 547 if (!$confirmedCurrency || $confirmedCurrency !== $transactionCurrency) {
513 548 $logData = [
@@ -560,8 +595,13 @@
560 595 ], 423);
561 596 }
562 597
563 598 $this->handlePaymentSuccess($charge, $transaction, $submission);
599 + } elseif ('processing' === $confirmation->status) {
600 + // was: fell through to failed; Stripe settles a delayed method later by webhook, so nothing is decided yet
601 + wp_send_json([
602 + 'errors' => __('Your payment is still being processed. You will be notified once it completes.', 'fluentform'),
603 + ], 423);
564 604 } else {
565 605 $this->handlePaymentChargeError('We could not verify your payment. Please try again', $submission, $transaction, $confirmation, 'payment_error');
566 606 }
567 607 }
@@ -585,8 +625,13 @@
585 625 'errors' => $validation->get_error_message(),
586 626 ], 423);
587 627 }
588 628
629 + // was: re-ran the status writers, so a repeated callback fired the payment-status hooks again
630 + if ($this->isPaymentAlreadyCompleted($submission, $transaction)) {
631 + $this->sendSuccess($submission);
632 + }
633 +
589 634 // Use submission's form_id rather than trusting $_REQUEST
590 635 $formId = $submission->form_id;
591 636
592 637 // Let's retrieve the intent
@@ -595,15 +640,81 @@
595 640 'invoice.payment_intent',
596 641 ],
597 642 ], $formId);
598 643
644 + // was: handlePaymentChargeError(), which marked a possibly paid submission failed on a Stripe outage
599 645 if (is_wp_error($intent)) {
600 - $this->handlePaymentChargeError($intent->get_error_message(), $submission, false, false, 'payment_intent');
646 + $this->sendRetryableVerificationError($submission, $intent);
601 647 }
602 648
603 649 $invoice = $intent->invoice;
604 650
651 + // was: settled from the objects read before the Stripe round-trip
652 + $this->refuseIfReversedMeanwhile($submission, $transaction, $intent->charges->data[0] ?? null);
653 +
605 654 $this->handlePaidSubscriptionInvoice($invoice, $submission);
655 + }
656 +
657 + // Re-read after the Stripe round-trip: a refund webhook may have run meanwhile, and Stripe's own
658 + // charge carries the refund state before that webhook arrives.
659 + protected function refuseIfReversedMeanwhile($submission, $transaction, $charge)
660 + {
661 + $current = Submission::find($submission->id);
662 + $currentTransaction = $transaction ? $this->getTransaction($transaction->id) : null;
663 +
664 + // A row deleted mid-request leaves nothing to settle, so it is refused like a reversal
665 + $rowVanished = !$current || ($transaction && !$currentTransaction);
666 + $reversedLocally = $rowVanished
667 + || PaymentHelper::isReversedPaymentStatus($current->payment_status)
668 + || ($currentTransaction && PaymentHelper::isReversedPaymentStatus($currentTransaction->status));
669 + // was: full refunds only, while the local check already treats partially-refunded as reversed
670 + $refundedAtStripe = $charge && (!empty($charge->refunded) || !empty($charge->amount_refunded));
671 +
672 + if (!$reversedLocally && !$refundedAtStripe) {
673 + return;
674 + }
675 +
676 + do_action('fluentform/log_data', [
677 + 'parent_source_id' => $submission->form_id,
678 + 'source_type' => 'submission_item',
679 + 'source_id' => $submission->id,
680 + 'component' => 'Payment',
681 + 'status' => 'warning',
682 + 'title' => __('Stripe confirmation refused', 'fluentform'),
683 + 'description' => $reversedLocally
684 + ? __('The payment was reversed before the confirmation completed.', 'fluentform')
685 + : __('Stripe reports the charge as refunded.', 'fluentform'),
686 + ]);
687 +
688 + wp_send_json([
689 + 'errors' => __('This payment has been refunded and cannot be confirmed.', 'fluentform'),
690 + ], 423);
691 + }
692 +
693 + // Transaction paid, submission paid and actions fired: nothing is left for a callback to finish.
694 + protected function isPaymentAlreadyCompleted($submission, $transaction)
695 + {
696 + return 'paid' === $transaction->status
697 + && 'paid' === $submission->payment_status
698 + && 'yes' === $this->getMetaData('is_form_action_fired');
699 + }
700 +
701 + // The outcome is unknown, not failed: no failure hooks, no status downgrade.
702 + protected function sendRetryableVerificationError($submission, \WP_Error $error)
703 + {
704 + do_action('fluentform/log_data', [
705 + 'parent_source_id' => $submission->form_id,
706 + 'source_type' => 'submission_item',
707 + 'source_id' => $submission->id,
708 + 'component' => 'Payment',
709 + 'status' => 'warning',
710 + 'title' => __('Stripe verification deferred', 'fluentform'),
711 + 'description' => $error->get_error_message(),
712 + ]);
713 +
714 + wp_send_json([
715 + 'errors' => __('We could not reach Stripe to verify your payment. Please try again in a moment.', 'fluentform'),
716 + ], 423);
606 717 }
607 718
608 719 protected function sendSuccess($submission)
609 720 {