PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.15
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.15
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/Classes/PaymentAction.php +329 -29 6.2.9 → 6.2.15 View file →
@@ -51,8 +51,10 @@
51 51 protected $discountCodes = [];
52 52
53 53 protected $couponField = [];
54 54
55 + private $decodedFormFields = null;
56 +
55 57 public function __construct($form, $insertData, $data)
56 58 {
57 59 $this->form = $form;
58 60 $this->data = $data;
@@ -85,9 +87,12 @@
85 87 if ('rangeslider' == $element && 'yes' != ArrayHelper::get($field, 'settings.enable_target_product')) {
86 88 continue;
87 89 }
88 90 if ($targetProductName = ArrayHelper::get($field, 'settings.target_product')) {
89 - $quantityItems[$targetProductName] = ArrayHelper::get($field, 'attributes.name');
91 + $quantityItems[$targetProductName] = [
92 + 'name' => ArrayHelper::get($field, 'attributes.name'),
93 + 'field' => $field,
94 + ];
90 95 }
91 96 } else if ($element == 'payment_method') {
92 97 $paymentMethod = $field;
93 98 } else if ($element == 'payment_coupon' && Helper::hasPro()) {
@@ -165,18 +170,23 @@
165 170 * not honored.
166 171 */
167 172 public function isCouponFieldVisible($couponField)
168 173 {
169 - if (!$this->isFieldConditionPass($couponField)) {
174 + return $this->isFieldVisible($couponField);
175 + }
176 +
177 + /**
178 + * Whether a field is actually shown to the submitter: its own conditional
179 + * logic has to pass, and so does that of every container it sits inside.
180 + */
181 + protected function isFieldVisible($field)
182 + {
183 + if (!$this->isFieldConditionPass($field)) {
170 184 return false;
171 185 }
172 186
173 - $formFields = $this->form->form_fields;
174 - if (is_string($formFields)) {
175 - $formFields = json_decode($formFields, true);
176 - }
177 - $couponName = ArrayHelper::get($couponField, 'attributes.name');
178 - $ancestors = $this->getFieldAncestorContainers(ArrayHelper::get($formFields, 'fields', []), $couponName);
187 + $fieldName = ArrayHelper::get($field, 'attributes.name');
188 + $ancestors = $this->getFieldAncestorContainers($this->getDecodedFormFields(), $fieldName);
179 189
180 190 foreach ((array) $ancestors as $container) {
181 191 if (!$this->isFieldConditionPass($container)) {
182 192 return false;
@@ -186,8 +196,163 @@
186 196 return true;
187 197 }
188 198
189 199 /**
200 + * The form definition does not change during a request, but this is now
201 + * consulted once per payment input and per subscription input, and
202 + * `applyDiscountCodes()` forces a full recompute -- so decoding it each time
203 + * meant re-parsing the whole form many times over on a multi-item order.
204 + */
205 + private function getDecodedFormFields()
206 + {
207 + if (is_null($this->decodedFormFields)) {
208 + $formFields = $this->form->form_fields;
209 + if (is_string($formFields)) {
210 + $formFields = json_decode($formFields, true);
211 + }
212 + $this->decodedFormFields = ArrayHelper::get($formFields, 'fields', []);
213 + }
214 +
215 + return $this->decodedFormFields;
216 + }
217 +
218 + /**
219 + * Which plan a subscription input is for.
220 + *
221 + * A submitted choice always wins, including an empty one. When the key never
222 + * arrived at all, the plan is inferred only from a plan the form actually
223 + * renders pre-selected -- which is exactly `is_default`, and nothing else:
224 + * a select leads with a blank "--Select Plan--" option and a radio group
225 + * starts unchecked, so on any other form not choosing is a real answer.
226 + * Where nothing is pre-selected the input is skipped rather than guessed at.
227 + *
228 + * @return string|int|null the plan key, or null when there is none to use
229 + */
230 + private function resolvePlanKey($subscriptionInput, $subscriptionOptions)
231 + {
232 + if (!$subscriptionOptions) {
233 + return null;
234 + }
235 +
236 + $name = ArrayHelper::get($subscriptionInput, 'attributes.name');
237 + $data = $this->submissionData['response'];
238 +
239 + // An empty submitted value is an answer, not a missing one. A select
240 + // renders a blank "--Select Plan--" placeholder first and the field is
241 + // optional by default, so choosing it is a genuine "no subscription".
242 + // Only a key that never arrived at all can be inferred.
243 + if (isset($data[$name])) {
244 + if ('' === $data[$name]) {
245 + return null;
246 + }
247 +
248 + return isset($subscriptionOptions[$data[$name]]) ? $data[$name] : null;
249 + }
250 +
251 + if (!$this->isFieldVisible($subscriptionInput)) {
252 + return null;
253 + }
254 +
255 + // Only a plan the form renders pre-selected may be inferred. The renderer
256 + // sets checked/selected solely for is_default and skips expired-hidden plans,
257 + // so any other plan is a choice the submitter still had to make -- and not
258 + // making it is legitimate, not tampering. Definitions carry no single-default
259 + // constraint, so a stale/imported form can mark several defaults or leave the
260 + // first default expired-hidden. Collect every inferable default and infer only
261 + // when exactly one remains; zero or many is ambiguous and needs an explicit
262 + // choice, so it resolves to no subscription rather than the wrong plan.
263 + $inferableDefaults = [];
264 + foreach ($subscriptionOptions as $planKey => $plan) {
265 + if ('yes' === ArrayHelper::get($plan, 'is_default') && $this->isInferablePlan($plan)) {
266 + $inferableDefaults[] = $planKey;
267 + }
268 + }
269 +
270 + return 1 === count($inferableDefaults) ? $inferableDefaults[0] : null;
271 + }
272 +
273 + /**
274 + * A plan may only be inferred when the form already knows what it costs and
275 + * still offers it.
276 + *
277 + * A "name your price" plan takes its amount from a companion input, so with
278 + * nothing submitted there is no amount to charge -- and inferring the plan
279 + * anyway would create a subscription at 0. Trial days make that worse: the
280 + * zero-amount guard further down is skipped while a trial is configured, so
281 + * the result would be a free perpetual subscription.
282 + *
283 + * An expired plan set to hide is never rendered at all, so its absence is
284 + * the admin closing sales, not a dropped input.
285 + */
286 + private function isInferablePlan($plan)
287 + {
288 + if ('yes' === ArrayHelper::get($plan, 'user_input')) {
289 + return false;
290 + }
291 +
292 + return !PaymentHelper::isPlanExpiredAndHidden($plan);
293 + }
294 +
295 + /**
296 + * Whether the server already knows this item's price, i.e. the request only
297 + * ever echoed back a value taken from the form definition.
298 + *
299 + * Those items must not be skippable by leaving the input out of the request,
300 + * because the submitter never supplied the amount in the first place. Items
301 + * the submitter genuinely chooses -- an option, a typed amount, a dynamic
302 + * default -- are excluded, so leaving those out stays a legitimate
303 + * zero-total submission.
304 + */
305 + private function isServerPricedItem($paymentInput, $inputType)
306 + {
307 + if ('single' !== $inputType) {
308 + return false;
309 + }
310 +
311 + if (ArrayHelper::get($paymentInput, 'settings.dynamic_default_value')) {
312 + return false;
313 + }
314 +
315 + $price = ArrayHelper::get($paymentInput, 'attributes.value');
316 + if (!is_numeric($price) || !$price) {
317 + return false;
318 + }
319 +
320 + if (
321 + 'yes' === ArrayHelper::get($paymentInput, 'settings.hide_input_when_stockout')
322 + && $this->proCannotJudgeHiddenStock()
323 + ) {
324 + return false;
325 + }
326 +
327 + if (!$this->isFieldVisible($paymentInput)) {
328 + return false;
329 + }
330 +
331 + // Lets an add-on that removes an input at render time -- for reasons the
332 + // stored definition cannot express -- keep it out of the order too.
333 + return (bool) apply_filters(
334 + 'fluentform/is_server_priced_payment_item',
335 + true,
336 + $paymentInput,
337 + $this->form
338 + );
339 + }
340 +
341 + /**
342 + * Pro before its renderer-count veto hides a sold-out item at render but judges
343 + * stock from a different count on submit, so an omitted hidden item can still read
344 + * as in stock and be charged. Until that Pro is updated its sites stay on the
345 + * presence check for the hide flag: the fail-open that leaves is the one they
346 + * already have, and charging a buyer for an item they never saw is worse.
347 + */
348 + protected function proCannotJudgeHiddenStock()
349 + {
350 + return defined('FLUENTFORMPRO')
351 + && !method_exists('\FluentFormPro\classes\Inventory\InventoryValidation', 'getRenderedEntryReport');
352 + }
353 +
354 + /**
190 355 * Return the ancestor container fields wrapping $targetName (containers nest
191 356 * children under columns[].fields[]), or null if not found in this branch.
192 357 */
193 358 public function getFieldAncestorContainers($fields, $targetName, $ancestors = [])
@@ -226,8 +391,17 @@
226 391 $items = $this->getOrderItems();
227 392
228 393 $existingSubmission = $this->checkForExistingSubmission();
229 394
395 + if (is_wp_error($existingSubmission)) {
396 + wp_send_json([
397 + 'errors' => __('This payment is already complete or still processing. Please wait for confirmation.', 'fluentform'),
398 + 'append_data' => [
399 + '__entry_intermediate_hash' => ArrayHelper::get($this->submissionData, 'response.__entry_intermediate_hash')
400 + ]
401 + ], 423);
402 + }
403 +
230 404 $formSettings = PaymentHelper::getFormSettings($this->form->id, 'public');
231 405 $submission = $this->submissionData;
232 406 $submission['payment_status'] = 'pending';
233 407 $submission['payment_method'] = $this->selectedPaymentMethod;
@@ -247,13 +421,9 @@
247 421 );
248 422 $submission = apply_filters('fluentform/payment_submission_data', $submission, $this->form);
249 423
250 424 if ($existingSubmission) {
251 - $insertId = $existingSubmission->id;
252 - Submission::where('id', $insertId)->update($submission);
253 -
254 - // delete the existing transactions here if any
255 - Transaction::where('submission_id', $insertId)->delete();
425 + $insertId = $this->updateExistingSubmission($existingSubmission, $submission);
256 426 } else {
257 427 $insertId = Submission::create($submission)->id;
258 428 $uidHash = md5(wp_generate_uuid4() . $insertId);
259 429 Helper::setSubmissionMeta($insertId, '_entry_uid_hash', $uidHash, $this->form->id);
@@ -368,16 +538,22 @@
368 538 $data = $this->submissionData['response'];
369 539
370 540 foreach ($paymentInputs as $paymentInput) {
371 541 $name = ArrayHelper::get($paymentInput, 'attributes.name');
372 - if (!$name || !isset($data[$name])) {
542 + if (!$name) {
373 543 continue;
374 544 }
375 545 $price = 0;
376 546 $inputType = ArrayHelper::get($paymentInput, 'attributes.type');
377 547
378 - if (!$data[$name]) {
379 - continue;
548 + // A server-priced item is resolved from the form definition, so an
549 + // absent or falsy request value must not drop it -- otherwise an
550 + // unauthenticated submitter zeroes the order simply by omitting the
551 + // input. Every other item still needs a submitted value.
552 + if (!$this->isServerPricedItem($paymentInput, $inputType)) {
553 + if (!isset($data[$name]) || !$data[$name]) {
554 + continue;
555 + }
380 556 }
381 557
382 558 if ($inputType == 'number') {
383 559 $price = $data[$name];
@@ -461,19 +637,35 @@
461 637 }
462 638 if (!isset($this->quantityItems[$productName])) {
463 639 return $quantity;
464 640 }
465 - $inputName = $this->quantityItems[$productName];
466 - $quantity = ArrayHelper::get($this->submissionData['response'], $inputName);
641 + $quantityField = $this->quantityItems[$productName]['field'];
642 + $inputName = $this->quantityItems[$productName]['name'];
643 + $data = $this->submissionData['response'];
644 +
645 + // An absent input is either hidden by conditional logic or stripped to zero
646 + // the order; only the field's own visibility separates the two. A submitted
647 + // 0 or blank box still means none.
648 + if (!isset($data[$inputName])) {
649 + return $this->isFieldVisible($quantityField) ? 1 : 0;
650 + }
651 +
652 + $quantity = ArrayHelper::get($data, $inputName);
467 653 if (!$quantity) {
468 654 return 0;
469 655 }
470 - return intval($quantity);
656 + // SECURITY (FINDING-22): clamp a user-supplied quantity to a non-negative integer so a
657 + // negative quantity cannot flip a line total and subtract from the order.
658 + return max(0, intval($quantity));
471 659 }
472 660
473 661 private function pushItem($data)
474 662 {
475 - if (!$data['item_price']) {
663 + // SECURITY (FINDING-22): reject non-positive prices. A user-controlled "name your price"
664 + // / donation amount (or a dynamic-default numeric field) is otherwise taken verbatim, and
665 + // a negative value subtracts from the order total — forcing it to exactly 0 makes
666 + // maybeHandlePayment() skip the gateway entirely, yielding a free fulfilled order.
667 + if (!is_numeric($data['item_price']) || floatval($data['item_price']) <= 0) {
476 668 return;
477 669 }
478 670 $data['item_price'] = floatval($data['item_price'] * 100);
479 671
@@ -547,8 +739,56 @@
547 739 }
548 740 return $total;
549 741 }
550 742
743 + // A $0 order is a completed free order when a real priced product was zeroed by a
744 + // server-validated discount (both a non-discount and a discount line are present;
745 + // discount lines come solely from getValidCoupons), or when the visitor chose one of
746 + // the form's own $0 options. Otherwise the $0 total is an omitted or zeroed input.
747 + public function isZeroTotalFreeOrder()
748 + {
749 + $hasProduct = $hasDiscount = false;
750 + foreach ($this->getOrderItems() as $item) {
751 + if (ArrayHelper::get($item, 'type') === 'discount') {
752 + $hasDiscount = true;
753 + } else {
754 + $hasProduct = true;
755 + }
756 + }
757 + return ($hasProduct && $hasDiscount) || $this->hasSelectedFreeOption();
758 + }
759 +
760 + // Validation rejects any option the form does not offer, so a selected $0 option is the site's own.
761 + protected function hasSelectedFreeOption()
762 + {
763 + $data = (array) ArrayHelper::get($this->submissionData, 'response');
764 + foreach ((array) $this->paymentInputs as $input) {
765 + $selected = (array) ArrayHelper::get($data, ArrayHelper::get($input, 'attributes.name'), []);
766 + if (!$selected || !$this->isFieldVisible($input)) {
767 + continue;
768 + }
769 + foreach (ArrayHelper::get($input, 'settings.pricing_options', []) as $option) {
770 + if (in_array(sanitize_text_field($option['label']), $selected) && !(float) $option['value']) {
771 + return true;
772 + }
773 + }
774 + }
775 + return false;
776 + }
777 +
778 + // The entry is not inserted yet; stamp a free order the moment it is, before any
779 + // payment-success consumer runs. An absent flag means an empty order.
780 + public function flagZeroTotalOrder()
781 + {
782 + if (!$this->isZeroTotalFreeOrder()) {
783 + return;
784 + }
785 +
786 + add_action('fluentform/notify_on_form_submit', function ($insertId, $formData, $form) {
787 + Helper::setSubmissionMeta($insertId, '_ff_zero_total_free_order', 'yes', $form->id);
788 + }, 1, 3);
789 + }
790 +
551 791 public function getPaymentType()
552 792 {
553 793 return count($this->getSubscriptionItems()) ? 'subscription' : 'product'; // return value product|subscription|donation
554 794 }
@@ -579,9 +819,9 @@
579 819 foreach ($subscriptionInputs as $subscriptionInput) {
580 820 $name = ArrayHelper::get($subscriptionInput, 'attributes.name');
581 821 $quantity = $this->getQuantity($name);
582 822
583 - if (!$name || !isset($data[$name]) || $quantity === 0) {
823 + if (!$name || $quantity === 0) {
584 824 continue;
585 825 }
586 826
587 827 $label = ArrayHelper::get($subscriptionInput, 'settings.label', $name);
@@ -587,17 +827,22 @@
587 827 $label = ArrayHelper::get($subscriptionInput, 'settings.label', $name);
588 828
589 829 $subscriptionOptions = ArrayHelper::get($subscriptionInput, 'settings.subscription_options');
590 830
591 - $plan = $subscriptionOptions[$data[$name]];
831 + $planKey = $this->resolvePlanKey($subscriptionInput, $subscriptionOptions);
592 832
833 + if (is_null($planKey)) {
834 + continue;
835 + }
836 +
837 + $plan = ArrayHelper::get($subscriptionOptions, $planKey);
838 +
593 839 if (!$plan) {
594 840 continue;
595 841 }
596 842
597 843 if (ArrayHelper::get($plan, 'user_input') === 'yes') {
598 - $plan['subscription_amount'] = ArrayHelper::get($data, $name . '_custom_' . $data[$name]);
599 - $plan['subscription_amount'] = $plan['subscription_amount'] ?: 0;
844 + $plan['subscription_amount'] = $this->getCustomSubscriptionAmount($data, $name, $planKey);
600 845 }
601 846
602 847 $noTrial = ArrayHelper::get($plan, 'has_trial_days') === 'no' ||
603 848 !ArrayHelper::get($plan, 'trial_days');
@@ -721,8 +966,17 @@
721 966
722 967 $submission = Submission::find($meta->response_id);
723 968
724 969 if ($submission && ($submission->payment_status == 'failed' || $submission->payment_status == 'pending' || $submission->payment_status == 'draft')) {
970 + // A settled charge means the earlier attempt went through after the error
971 + // was shown; reusing the row would delete that ledger entry.
972 + $hasPaidOrProcessingCharge = Transaction::where('submission_id', $submission->id)
973 + ->whereIn('status', ['paid', 'processing'])
974 + ->exists();
975 + if ($hasPaidOrProcessingCharge) {
976 + return new \WP_Error('payment_retry_blocked');
977 + }
978 +
725 979 return $submission;
726 980 }
727 981
728 982 return false;
@@ -727,8 +981,23 @@
727 981
728 982 return false;
729 983 }
730 984
985 + /**
986 + * Update a retry in place while retaining its transaction rows for
987 + * processor reuse and delayed webhook settlement.
988 + */
989 + private function updateExistingSubmission($existingSubmission, $submission)
990 + {
991 + $submissionId = $existingSubmission->id;
992 + Submission::where('id', $submissionId)->update($submission);
993 + Transaction::where('submission_id', $submissionId)
994 + ->where('status', 'failed')
995 + ->delete();
996 +
997 + return $submissionId;
998 + }
999 +
731 1000 private function insertOrderItems($items, $existing = false)
732 1001 {
733 1002 if (!$existing) {
734 1003 foreach ($items as $item) {
@@ -771,14 +1040,33 @@
771 1040 }
772 1041 }
773 1042
774 1043 if ($verifiedIds) {
775 - OrderItem::whereNotIn('id', $verifiedIds)->delete();
1044 + // SECURITY (PRO-06): scope this stale-item cleanup to the current submission; the
1045 + // unscoped whereNotIn deleted every other submission's order_items site-wide (and
1046 + // fired on ordinary payment retries — a live data-loss bug).
1047 + OrderItem::where('submission_id', $existing->id)
1048 + ->whereNotIn('id', $verifiedIds)
1049 + ->delete();
776 1050 }
777 1051
778 1052 return true;
779 1053 }
780 1054
1055 + private function getCustomSubscriptionAmount($data, $name, $planKey)
1056 + {
1057 + $amount = ArrayHelper::get($data, $name . '_custom_' . $planKey) ?: 0;
1058 +
1059 + // Past PHP_INT_MAX cents, convertToCents() returns 0 or wraps negative, which drops the plan and leaves the order unpaid.
1060 + if (abs((float) $amount) >= PHP_INT_MAX / 100) {
1061 + wp_send_json([
1062 + 'errors' => [__('This subscription plan value is invalid', 'fluentform')]
1063 + ], 423);
1064 + }
1065 +
1066 + return $amount;
1067 + }
1068 +
781 1069 private function validateSubscriptionInputs()
782 1070 {
783 1071 $subscriptionInputs = $this->subscriptionInputs;
784 1072 if (!$subscriptionInputs) {
@@ -795,16 +1083,22 @@
795 1083 if (!$quantity) {
796 1084 continue;
797 1085 }
798 1086
799 - if (!$name || !isset($data[$name])) {
1087 + if (!$name) {
800 1088 continue;
801 1089 }
802 1090
803 1091 $subscriptionOptions = ArrayHelper::get($subscriptionInput, 'settings.subscription_options');
804 1092
805 - $plan = $subscriptionOptions[$data[$name]];
1093 + $planKey = $this->resolvePlanKey($subscriptionInput, $subscriptionOptions);
806 1094
1095 + if (is_null($planKey)) {
1096 + continue;
1097 + }
1098 +
1099 + $plan = ArrayHelper::get($subscriptionOptions, $planKey);
1100 +
807 1101 if (!$plan) {
808 1102 continue;
809 1103 }
810 1104
@@ -822,13 +1116,19 @@
822 1116
823 1117 // We have bill times 1 so we have to remove this and push to hooked inputs and later merged to payment inputs
824 1118
825 1119 if (ArrayHelper::get($plan, 'user_input') === 'yes') {
826 - $plan['subscription_amount'] = ArrayHelper::get($data, $name . '_custom_' . $data[$name]);
827 - $plan['subscription_amount'] = $plan['subscription_amount'] ?: 0;
1120 + $plan['subscription_amount'] = $this->getCustomSubscriptionAmount($data, $name, $planKey);
828 1121 }
829 1122
830 1123 $amount = PaymentHelper::convertToCents($plan['subscription_amount']);
1124 +
1125 + // Bypasses pushItem()'s positive-price guard, so a negative custom amount would
1126 + // otherwise become a line item that drags the order total down. Checked before
1127 + // the signup fee so the fee cannot mask it.
1128 + if ($amount < 0) {
1129 + continue;
1130 + }
831 1131
832 1132 if (ArrayHelper::get($plan, 'has_signup_fee') === 'yes' && ArrayHelper::get($plan, 'signup_fee')) {
833 1133 $amount += PaymentHelper::convertToCents($plan['signup_fee']);
834 1134 }