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/StripeProcessor.php +145 -14 6.2.12 → trunk View file →
@@ -406,14 +406,35 @@
406 406 if ($transaction->status == 'paid' && $submission->payment_status == 'paid') {
407 407 return $this->getReturnData();
408 408 }
409 409
410 + if (PaymentHelper::isReversedPaymentStatus($submission->payment_status)) {
411 + return $this->reversedDisplayData($submission);
412 + }
413 +
410 414 $invoice = empty($session->subscription->latest_invoice) ? $session : $session->subscription->latest_invoice;
411 415
412 - $paymentStatus = $this->getIntentSuccessName($invoice->payment_intent);
416 + // was: intent only; a $0 invoice has none, so the invoice decides
417 + $paymentStatus = $this->getIntentSuccessName($invoice->payment_intent, $invoice);
413 418
414 - $this->changeSubmissionPaymentStatus($paymentStatus);
419 + if (!$paymentStatus) {
420 + // Intent did not succeed (declined / requires_action / processing / etc.):
421 + // do NOT run success processing (status->paid, subscription activation,
422 + // fulfillment, action firing). Leave the status as-is so a later webhook can
423 + // still settle it, and return display data only.
424 + return [
425 + 'insert_id' => $submission->id,
426 + 'result' => false,
427 + 'title' => __('Payment Pending', 'fluentform'),
428 + 'error' => __('Your payment is being processed and hasn\'t been confirmed yet. You\'ll receive a confirmation once it completes.', 'fluentform'),
429 + ];
430 + }
415 431
432 + // was: an unconditional write; a refund recorded after the check above was overwritten with paid
433 + if (!$this->changeSubmissionPaymentStatusUnlessReversed($paymentStatus)) {
434 + return $this->reversedDisplayData($submission);
435 + }
436 +
416 437 if($transaction->transaction_type == 'subscription') {
417 438 $subscriptions = $this->getSubscriptions();
418 439
419 440 if($subscriptions) {
@@ -425,9 +446,11 @@
425 446 }
426 447 }
427 448 }
428 449
429 - $this->processOneTimeSuccess($invoice, $transaction, $paymentStatus);
450 + if (!$this->processOneTimeSuccess($invoice, $transaction, $paymentStatus)) {
451 + return $this->reversedDisplayData($submission);
452 + }
430 453
431 454 $returnData = $this->completePaymentSubmission(false);
432 455 $this->recalculatePaidTotal();
433 456 $returnData['is_new'] = $this->getMetaData('is_form_action_fired') === 'yes';
@@ -434,12 +457,29 @@
434 457
435 458 return $returnData;
436 459 }
437 460
438 - protected function getIntentSuccessName($intent)
461 + /**
462 + * Stripe issues no PaymentIntent for an invoice that owes nothing -- a trial, or a
463 + * first invoice discounted to zero. Such an invoice is already settled, so the
464 + * absence of an intent must not be read as an unsettled payment.
465 + *
466 + * All three conditions are required: a declined or in-flight intent is a non-null
467 + * object, so it can never reach here, and a nonzero or unpaid invoice is still denied.
468 + */
469 + protected function isSettledZeroInvoice($invoice)
439 470 {
471 + return empty($invoice->payment_intent)
472 + && isset($invoice->status, $invoice->amount_due)
473 + && 'paid' === $invoice->status
474 + && 0 === (int) $invoice->amount_due;
475 + }
476 +
477 + protected function getIntentSuccessName($intent, $invoice = null)
478 + {
440 479 if (!$intent || !$intent->status) {
441 - return false;
480 + // was: false, which the callers then stored as the string '0' for a settled $0 invoice
481 + return $this->isSettledZeroInvoice($invoice) ? 'paid' : false;
442 482 }
443 483
444 484 $successStatuses = [
445 485 'succeeded' => 'paid',
@@ -503,8 +543,18 @@
503 543 if (PaymentHelper::isZeroDecimal($data->currency)) {
504 544 $amountRefunded = $amountRefunded * 100;
505 545 }
506 546
547 + // amount_refunded is cumulative per charge; a subscription's other invoices refund separately.
548 + $chargeRefunded = (int) Transaction::bySubmission($submission->id)
549 + ->refunds()
550 + ->where('charge_id', $chargeId)
551 + ->sum('payment_total');
552 +
553 + if ($amountRefunded <= $chargeRefunded) {
554 + return;
555 + }
556 +
507 557 // Remove All Existing Refunds
508 558 Transaction::bySubmission($submission->id)->refunds()->delete();
509 559
510 560 $this->refund($amountRefunded, $transaction, $submission, 'stripe', $chargeId, 'Refund from Stripe');
@@ -516,8 +566,19 @@
516 566 $stripeSettings = StripeSettings::getSettings();
517 567 return $stripeSettings['payment_mode'];
518 568 }
519 569
570 + // Already reversed (refund/partial-refund/cancel): never resurrect to paid or fire the pipeline.
571 + // Callers reach here with the latch unset, and getReturnData() would fire it, so display data only.
572 + protected function reversedDisplayData($submission)
573 + {
574 + return [
575 + 'insert_id' => $submission->id,
576 + 'result' => false,
577 + 'error' => __('This payment has been reversed.', 'fluentform'),
578 + ];
579 + }
580 +
520 581 public function processSubscriptionSuccess($subscriptions, $invoice, $submission)
521 582 {
522 583 foreach ($subscriptions as $subscription) {
523 584 $subscriptionStatus = 'active';
@@ -609,20 +670,29 @@
609 670
610 671 protected function processOneTimeSuccess($invoice, $transaction, $paymentStatus)
611 672 {
612 673 if ($transaction) {
674 + // was: also 'status' => 'paid' here, an unconditional write the guarded one below always replaced
613 675 $updateData = [
614 676 'charge_id' => $invoice->payment_intent ? $invoice->payment_intent->id : null,
615 - 'status' => 'paid',
616 677 'payment_note' => maybe_serialize($invoice->payment_intent)
617 678 ];
618 679
680 + // was: charge_id null and note null; with no intent the invoice id is the row's only identity
681 + if ($this->isSettledZeroInvoice($invoice) && !empty($invoice->id)) {
682 + $updateData['charge_id'] = $invoice->id;
683 + $updateData['payment_note'] = maybe_serialize($invoice);
684 + $updateData['payment_total'] = 0;
685 + }
686 +
619 687 $updateData = array_merge($updateData, $this->retrieveCustomerDetailsFromInvoice($invoice));
620 688
621 689 $this->updateTransaction($transaction->id, $updateData);
622 690
623 - $this->changeTransactionStatus($transaction->id, $paymentStatus);
691 + return $this->changeTransactionStatusUnlessReversed($transaction->id, $paymentStatus);
624 692 }
693 +
694 + return true;
625 695 }
626 696
627 697 protected function getIntentMetaData($submission, $form, $transaction, $paymentSettings = false)
628 698 {
@@ -711,9 +781,10 @@
711 781
712 782 return $customer;
713 783 }
714 784
715 - protected function handlePaymentChargeError($message, $submission, $transaction, $charge = false, $type = 'general')
785 + // One contract for every Stripe failure, whichever path reports it: the inline flows and the delayed Checkout webhook.
786 + public function fireFailureHooks($submission, $transaction, $formId, $charge = false, $type = 'general')
716 787 {
717 788 do_action_deprecated(
718 789 'fluentform_payment_stripe_failed',
719 790 [
@@ -718,9 +789,9 @@
718 789 'fluentform_payment_stripe_failed',
719 790 [
720 791 $submission,
721 792 $transaction,
722 - $this->form->id,
793 + $formId,
723 794 $charge,
724 795 $type
725 796 ],
726 797 FLUENTFORM_FRAMEWORK_UPGRADE,
@@ -727,9 +798,9 @@
727 798 'fluentform/payment_stripe_failed',
728 799 'Use fluentform/payment_stripe_failed instead of fluentform_payment_stripe_failed.'
729 800 );
730 801
731 - do_action('fluentform/payment_stripe_failed', $submission, $transaction, $this->form->id, $charge, $type);
802 + do_action('fluentform/payment_stripe_failed', $submission, $transaction, $formId, $charge, $type);
732 803
733 804 do_action_deprecated(
734 805 'fluentform_payment_failed',
735 806 [
@@ -734,9 +805,9 @@
734 805 'fluentform_payment_failed',
735 806 [
736 807 $submission,
737 808 $transaction,
738 - $this->form->id,
809 + $formId,
739 810 $charge,
740 811 $type
741 812 ],
742 813 FLUENTFORM_FRAMEWORK_UPGRADE,
@@ -742,15 +813,21 @@
742 813 FLUENTFORM_FRAMEWORK_UPGRADE,
743 814 'fluentform/payment_failed',
744 815 'Use fluentform/payment_failed instead of fluentform_payment_failed.'
745 816 );
746 - do_action('fluentform/payment_failed', $submission, $transaction, $this->form->id, $charge, $type);
817 + do_action('fluentform/payment_failed', $submission, $transaction, $formId, $charge, $type);
818 + }
747 819
820 + protected function handlePaymentChargeError($message, $submission, $transaction, $charge = false, $type = 'general', $error = null)
821 + {
822 + $this->fireFailureHooks($submission, $transaction, $this->form->id, $charge, $type);
823 +
824 + $isIndeterminate = is_wp_error($error) && 'stripe_error' === $error->get_error_code();
748 825 if ($transaction) {
749 - $this->changeTransactionStatus($transaction->id, 'failed');
826 + $this->changeTransactionStatus($transaction->id, $isIndeterminate ? 'processing' : 'failed');
750 827 }
751 828
752 - $this->changeSubmissionPaymentStatus('failed');
829 + $this->changeSubmissionPaymentStatus($isIndeterminate ? 'pending' : 'failed');
753 830
754 831 if ($message) {
755 832 $logData = [
756 833 'parent_source_id' => $submission->form_id,
@@ -770,8 +847,62 @@
770 847 'append_data' => [
771 848 '__entry_intermediate_hash' => Helper::getSubmissionMeta($submission->id, '__entry_intermediate_hash')
772 849 ]
773 850 ], 423);
851 + }
852 +
853 + protected function isCountableSubscriptionPayment($payment, $subscription)
854 + {
855 + if ($this->isTrialStartInvoice($payment, $subscription)) {
856 + return false;
857 + }
858 +
859 + if ($payment->payment_total > 0) {
860 + return true;
861 + }
862 +
863 + $invoice = (array) Helper::safeUnserialize($payment->payment_note);
864 + $reason = $invoice['billing_reason'] ?? null;
865 +
866 + return 'subscription_cycle' === $reason
867 + || ('subscription_create' === $reason && (!$subscription || !$subscription->trial_days));
868 + }
869 +
870 + protected function isTrialStartInvoice($payment, $subscription)
871 + {
872 + if (!$subscription || !$subscription->trial_days || 'stripe' !== $payment->payment_method) {
873 + return false;
874 + }
875 +
876 + $storedPayment = (array) Helper::safeUnserialize($payment->payment_note);
877 +
878 + if ('subscription_create' === ($storedPayment['billing_reason'] ?? null)) {
879 + return true;
880 + }
881 +
882 + // Match legacy invoice or browser intent data only to the saved initial invoice.
883 + $initialInvoice = (array) Helper::safeUnserialize($subscription->vendor_response);
884 + $storedId = $storedPayment['id'] ?? null;
885 +
886 + if (!$storedId) {
887 + return false;
888 + }
889 +
890 + if ('invoice' === ($storedPayment['object'] ?? null)) {
891 + return 0 === strcmp((string) ($initialInvoice['id'] ?? ''), (string) $storedId);
892 + }
893 +
894 + if ('payment_intent' !== ($storedPayment['object'] ?? null)) {
895 + return false;
896 + }
897 +
898 + $initialIntent = $initialInvoice['payment_intent'] ?? null;
899 + if (is_object($initialIntent) || is_array($initialIntent)) {
900 + $initialIntent = (array) $initialIntent;
901 + $initialIntent = $initialIntent['id'] ?? null;
902 + }
903 +
904 + return $initialIntent === $storedId;
774 905 }
775 906
776 907 public function recordSubscriptionCharge($subscription, $transactionData)
777 908 {