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 +161 -16 6.2.11 → trunk View file →
@@ -182,9 +182,12 @@
182 182 if ($transaction->transaction_type == 'subscription') {
183 183 $checkoutArgs['subscription_data']['application_fee_percent'] = 1.9; // 1.9%
184 184 } else {
185 185 // Total amount of 1.9%
186 - $applicationFeeAmount = (int) ($transaction->payment_total * 0.019);
186 + $applicationFeeAmount = $this->calculateApplicationFeeAmount(
187 + $transaction->payment_total,
188 + $transaction->currency
189 + );
187 190 $checkoutArgs['payment_intent_data']['application_fee_amount'] = $applicationFeeAmount;
188 191 }
189 192 }
190 193
@@ -226,8 +229,17 @@
226 229 ]
227 230 ], 200);
228 231 }
229 232
233 + protected function calculateApplicationFeeAmount($paymentTotal, $currency)
234 + {
235 + if (PaymentHelper::isZeroDecimal($currency)) {
236 + $paymentTotal = intval($paymentTotal / 100);
237 + }
238 +
239 + return (int) ($paymentTotal * 0.019);
240 + }
241 +
230 242 protected function getPaymentIntentData($transaction, $submission, $form)
231 243 {
232 244 $data = [
233 245 'capture_method' => 'automatic',
@@ -394,14 +406,35 @@
394 406 if ($transaction->status == 'paid' && $submission->payment_status == 'paid') {
395 407 return $this->getReturnData();
396 408 }
397 409
410 + if (PaymentHelper::isReversedPaymentStatus($submission->payment_status)) {
411 + return $this->reversedDisplayData($submission);
412 + }
413 +
398 414 $invoice = empty($session->subscription->latest_invoice) ? $session : $session->subscription->latest_invoice;
399 415
400 - $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);
401 418
402 - $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 + }
403 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 +
404 437 if($transaction->transaction_type == 'subscription') {
405 438 $subscriptions = $this->getSubscriptions();
406 439
407 440 if($subscriptions) {
@@ -413,9 +446,11 @@
413 446 }
414 447 }
415 448 }
416 449
417 - $this->processOneTimeSuccess($invoice, $transaction, $paymentStatus);
450 + if (!$this->processOneTimeSuccess($invoice, $transaction, $paymentStatus)) {
451 + return $this->reversedDisplayData($submission);
452 + }
418 453
419 454 $returnData = $this->completePaymentSubmission(false);
420 455 $this->recalculatePaidTotal();
421 456 $returnData['is_new'] = $this->getMetaData('is_form_action_fired') === 'yes';
@@ -422,12 +457,29 @@
422 457
423 458 return $returnData;
424 459 }
425 460
426 - 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)
427 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 + {
428 479 if (!$intent || !$intent->status) {
429 - 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;
430 482 }
431 483
432 484 $successStatuses = [
433 485 'succeeded' => 'paid',
@@ -491,8 +543,18 @@
491 543 if (PaymentHelper::isZeroDecimal($data->currency)) {
492 544 $amountRefunded = $amountRefunded * 100;
493 545 }
494 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 +
495 557 // Remove All Existing Refunds
496 558 Transaction::bySubmission($submission->id)->refunds()->delete();
497 559
498 560 $this->refund($amountRefunded, $transaction, $submission, 'stripe', $chargeId, 'Refund from Stripe');
@@ -504,8 +566,19 @@
504 566 $stripeSettings = StripeSettings::getSettings();
505 567 return $stripeSettings['payment_mode'];
506 568 }
507 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 +
508 581 public function processSubscriptionSuccess($subscriptions, $invoice, $submission)
509 582 {
510 583 foreach ($subscriptions as $subscription) {
511 584 $subscriptionStatus = 'active';
@@ -597,20 +670,29 @@
597 670
598 671 protected function processOneTimeSuccess($invoice, $transaction, $paymentStatus)
599 672 {
600 673 if ($transaction) {
674 + // was: also 'status' => 'paid' here, an unconditional write the guarded one below always replaced
601 675 $updateData = [
602 676 'charge_id' => $invoice->payment_intent ? $invoice->payment_intent->id : null,
603 - 'status' => 'paid',
604 677 'payment_note' => maybe_serialize($invoice->payment_intent)
605 678 ];
606 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 +
607 687 $updateData = array_merge($updateData, $this->retrieveCustomerDetailsFromInvoice($invoice));
608 688
609 689 $this->updateTransaction($transaction->id, $updateData);
610 690
611 - $this->changeTransactionStatus($transaction->id, $paymentStatus);
691 + return $this->changeTransactionStatusUnlessReversed($transaction->id, $paymentStatus);
612 692 }
693 +
694 + return true;
613 695 }
614 696
615 697 protected function getIntentMetaData($submission, $form, $transaction, $paymentSettings = false)
616 698 {
@@ -628,8 +710,10 @@
628 710
629 711 $metaItems = ArrayHelper::get($paymentSettings, 'stripe_meta_data', []);
630 712 if ((ArrayHelper::get($paymentSettings, 'push_meta_to_stripe') == 'yes') && !empty($metaItems)) {
631 713
714 + $metaData = [];
715 +
632 716 foreach ($metaItems as $metaItem) {
633 717 if ($itemValue = ArrayHelper::get($metaItem, 'item_value')) {
634 718 $metaData[ArrayHelper::get($metaItem, 'label', 'item')] = $itemValue;
635 719 }
@@ -636,9 +720,9 @@
636 720 }
637 721
638 722 $metaData = ShortCodeParser::parse($metaData, $submission->id, $submission->response);
639 723
640 - $metaData = array_filter($metaData);
724 + $metaData = is_array($metaData) ? array_filter($metaData) : [];
641 725
642 726 foreach ($metaData as $itemKey => $value) {
643 727 if (is_string($value) || is_numeric($value)) {
644 728 $metaData[$itemKey] = wp_strip_all_tags($value);
@@ -697,9 +781,10 @@
697 781
698 782 return $customer;
699 783 }
700 784
701 - 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')
702 787 {
703 788 do_action_deprecated(
704 789 'fluentform_payment_stripe_failed',
705 790 [
@@ -704,9 +789,9 @@
704 789 'fluentform_payment_stripe_failed',
705 790 [
706 791 $submission,
707 792 $transaction,
708 - $this->form->id,
793 + $formId,
709 794 $charge,
710 795 $type
711 796 ],
712 797 FLUENTFORM_FRAMEWORK_UPGRADE,
@@ -713,9 +798,9 @@
713 798 'fluentform/payment_stripe_failed',
714 799 'Use fluentform/payment_stripe_failed instead of fluentform_payment_stripe_failed.'
715 800 );
716 801
717 - 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);
718 803
719 804 do_action_deprecated(
720 805 'fluentform_payment_failed',
721 806 [
@@ -720,9 +805,9 @@
720 805 'fluentform_payment_failed',
721 806 [
722 807 $submission,
723 808 $transaction,
724 - $this->form->id,
809 + $formId,
725 810 $charge,
726 811 $type
727 812 ],
728 813 FLUENTFORM_FRAMEWORK_UPGRADE,
@@ -728,15 +813,21 @@
728 813 FLUENTFORM_FRAMEWORK_UPGRADE,
729 814 'fluentform/payment_failed',
730 815 'Use fluentform/payment_failed instead of fluentform_payment_failed.'
731 816 );
732 - do_action('fluentform/payment_failed', $submission, $transaction, $this->form->id, $charge, $type);
817 + do_action('fluentform/payment_failed', $submission, $transaction, $formId, $charge, $type);
818 + }
733 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();
734 825 if ($transaction) {
735 - $this->changeTransactionStatus($transaction->id, 'failed');
826 + $this->changeTransactionStatus($transaction->id, $isIndeterminate ? 'processing' : 'failed');
736 827 }
737 828
738 - $this->changeSubmissionPaymentStatus('failed');
829 + $this->changeSubmissionPaymentStatus($isIndeterminate ? 'pending' : 'failed');
739 830
740 831 if ($message) {
741 832 $logData = [
742 833 'parent_source_id' => $submission->form_id,
@@ -756,8 +847,62 @@
756 847 'append_data' => [
757 848 '__entry_intermediate_hash' => Helper::getSubmissionMeta($submission->id, '__entry_intermediate_hash')
758 849 ]
759 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;
760 905 }
761 906
762 907 public function recordSubscriptionCharge($subscription, $transactionData)
763 908 {