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 +389 -23 6.2.2 → 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()) {
@@ -109,9 +114,9 @@
109 114 $this->methodSettings = ArrayHelper::get($paymentMethod, 'settings.payment_methods.' . $this->selectedPaymentMethod);
110 115 }
111 116 }
112 117
113 - if ($couponField) {
118 + if ($couponField && $this->isCouponFieldVisible($couponField)) {
114 119 $couponCodes = ArrayHelper::get($this->data, '__ff_all_applied_coupons', '');
115 120 if ($couponCodes) {
116 121 $couponCodes = \json_decode($couponCodes, true);
117 122 if ($couponCodes && class_exists('FluentFormPro\Payments\Classes\CouponModel')) {
@@ -144,8 +149,234 @@
144 149 $conditionFeed = ['conditionals' => $conditionSettings];
145 150 return ConditionAssesor::evaluate($conditionFeed, $this->data);
146 151 }
147 152
153 + public function isFieldConditionPass($field)
154 + {
155 + $conditionSettings = ArrayHelper::get($field, 'settings.conditional_logics', []);
156 + if (
157 + !$conditionSettings ||
158 + !ArrayHelper::isTrue($conditionSettings, 'status')
159 + ) {
160 + return true;
161 + }
162 +
163 + $conditionFeed = ['conditionals' => $conditionSettings];
164 + return ConditionAssesor::evaluate($conditionFeed, $this->data);
165 + }
166 +
167 + /**
168 + * Visible only when the coupon field's own conditions and every ancestor
169 + * container's conditions pass — so a coupon inside a hidden container is
170 + * not honored.
171 + */
172 + public function isCouponFieldVisible($couponField)
173 + {
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)) {
184 + return false;
185 + }
186 +
187 + $fieldName = ArrayHelper::get($field, 'attributes.name');
188 + $ancestors = $this->getFieldAncestorContainers($this->getDecodedFormFields(), $fieldName);
189 +
190 + foreach ((array) $ancestors as $container) {
191 + if (!$this->isFieldConditionPass($container)) {
192 + return false;
193 + }
194 + }
195 +
196 + return true;
197 + }
198 +
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 + /**
355 + * Return the ancestor container fields wrapping $targetName (containers nest
356 + * children under columns[].fields[]), or null if not found in this branch.
357 + */
358 + public function getFieldAncestorContainers($fields, $targetName, $ancestors = [])
359 + {
360 + foreach ($fields as $field) {
361 + if (ArrayHelper::get($field, 'attributes.name') === $targetName) {
362 + return $ancestors;
363 + }
364 + foreach (ArrayHelper::get($field, 'columns', []) as $column) {
365 + $found = $this->getFieldAncestorContainers(
366 + ArrayHelper::get($column, 'fields', []),
367 + $targetName,
368 + array_merge($ancestors, [$field])
369 + );
370 + if (!is_null($found)) {
371 + return $found;
372 + }
373 + }
374 + }
375 +
376 + return null;
377 + }
378 +
148 379 public function draftFormEntry()
149 380 {
150 381 // Record Payment Items
151 382 $subscriptionItems = $this->getSubscriptionItems();
@@ -160,8 +391,17 @@
160 391 $items = $this->getOrderItems();
161 392
162 393 $existingSubmission = $this->checkForExistingSubmission();
163 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 +
164 404 $formSettings = PaymentHelper::getFormSettings($this->form->id, 'public');
165 405 $submission = $this->submissionData;
166 406 $submission['payment_status'] = 'pending';
167 407 $submission['payment_method'] = $this->selectedPaymentMethod;
@@ -181,13 +421,9 @@
181 421 );
182 422 $submission = apply_filters('fluentform/payment_submission_data', $submission, $this->form);
183 423
184 424 if ($existingSubmission) {
185 - $insertId = $existingSubmission->id;
186 - Submission::where('id', $insertId)->update($submission);
187 -
188 - // delete the existing transactions here if any
189 - Transaction::where('submission_id', $insertId)->delete();
425 + $insertId = $this->updateExistingSubmission($existingSubmission, $submission);
190 426 } else {
191 427 $insertId = Submission::create($submission)->id;
192 428 $uidHash = md5(wp_generate_uuid4() . $insertId);
193 429 Helper::setSubmissionMeta($insertId, '_entry_uid_hash', $uidHash, $this->form->id);
@@ -302,16 +538,22 @@
302 538 $data = $this->submissionData['response'];
303 539
304 540 foreach ($paymentInputs as $paymentInput) {
305 541 $name = ArrayHelper::get($paymentInput, 'attributes.name');
306 - if (!$name || !isset($data[$name])) {
542 + if (!$name) {
307 543 continue;
308 544 }
309 545 $price = 0;
310 546 $inputType = ArrayHelper::get($paymentInput, 'attributes.type');
311 547
312 - if (!$data[$name]) {
313 - 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 + }
314 556 }
315 557
316 558 if ($inputType == 'number') {
317 559 $price = $data[$name];
@@ -395,19 +637,35 @@
395 637 }
396 638 if (!isset($this->quantityItems[$productName])) {
397 639 return $quantity;
398 640 }
399 - $inputName = $this->quantityItems[$productName];
400 - $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);
401 653 if (!$quantity) {
402 654 return 0;
403 655 }
404 - 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));
405 659 }
406 660
407 661 private function pushItem($data)
408 662 {
409 - 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) {
410 668 return;
411 669 }
412 670 $data['item_price'] = floatval($data['item_price'] * 100);
413 671
@@ -481,8 +739,56 @@
481 739 }
482 740 return $total;
483 741 }
484 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 +
485 791 public function getPaymentType()
486 792 {
487 793 return count($this->getSubscriptionItems()) ? 'subscription' : 'product'; // return value product|subscription|donation
488 794 }
@@ -513,9 +819,9 @@
513 819 foreach ($subscriptionInputs as $subscriptionInput) {
514 820 $name = ArrayHelper::get($subscriptionInput, 'attributes.name');
515 821 $quantity = $this->getQuantity($name);
516 822
517 - if (!$name || !isset($data[$name]) || $quantity === 0) {
823 + if (!$name || $quantity === 0) {
518 824 continue;
519 825 }
520 826
521 827 $label = ArrayHelper::get($subscriptionInput, 'settings.label', $name);
@@ -521,17 +827,22 @@
521 827 $label = ArrayHelper::get($subscriptionInput, 'settings.label', $name);
522 828
523 829 $subscriptionOptions = ArrayHelper::get($subscriptionInput, 'settings.subscription_options');
524 830
525 - $plan = $subscriptionOptions[$data[$name]];
831 + $planKey = $this->resolvePlanKey($subscriptionInput, $subscriptionOptions);
526 832
833 + if (is_null($planKey)) {
834 + continue;
835 + }
836 +
837 + $plan = ArrayHelper::get($subscriptionOptions, $planKey);
838 +
527 839 if (!$plan) {
528 840 continue;
529 841 }
530 842
531 843 if (ArrayHelper::get($plan, 'user_input') === 'yes') {
532 - $plan['subscription_amount'] = ArrayHelper::get($data, $name . '_custom_' . $data[$name]);
533 - $plan['subscription_amount'] = $plan['subscription_amount'] ?: 0;
844 + $plan['subscription_amount'] = $this->getCustomSubscriptionAmount($data, $name, $planKey);
534 845 }
535 846
536 847 $noTrial = ArrayHelper::get($plan, 'has_trial_days') === 'no' ||
537 848 !ArrayHelper::get($plan, 'trial_days');
@@ -655,8 +966,17 @@
655 966
656 967 $submission = Submission::find($meta->response_id);
657 968
658 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 +
659 979 return $submission;
660 980 }
661 981
662 982 return false;
@@ -661,8 +981,23 @@
661 981
662 982 return false;
663 983 }
664 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 +
665 1000 private function insertOrderItems($items, $existing = false)
666 1001 {
667 1002 if (!$existing) {
668 1003 foreach ($items as $item) {
@@ -705,14 +1040,33 @@
705 1040 }
706 1041 }
707 1042
708 1043 if ($verifiedIds) {
709 - 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();
710 1050 }
711 1051
712 1052 return true;
713 1053 }
714 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 +
715 1069 private function validateSubscriptionInputs()
716 1070 {
717 1071 $subscriptionInputs = $this->subscriptionInputs;
718 1072 if (!$subscriptionInputs) {
@@ -729,16 +1083,22 @@
729 1083 if (!$quantity) {
730 1084 continue;
731 1085 }
732 1086
733 - if (!$name || !isset($data[$name])) {
1087 + if (!$name) {
734 1088 continue;
735 1089 }
736 1090
737 1091 $subscriptionOptions = ArrayHelper::get($subscriptionInput, 'settings.subscription_options');
738 1092
739 - $plan = $subscriptionOptions[$data[$name]];
1093 + $planKey = $this->resolvePlanKey($subscriptionInput, $subscriptionOptions);
740 1094
1095 + if (is_null($planKey)) {
1096 + continue;
1097 + }
1098 +
1099 + $plan = ArrayHelper::get($subscriptionOptions, $planKey);
1100 +
741 1101 if (!$plan) {
742 1102 continue;
743 1103 }
744 1104
@@ -756,13 +1116,19 @@
756 1116
757 1117 // We have bill times 1 so we have to remove this and push to hooked inputs and later merged to payment inputs
758 1118
759 1119 if (ArrayHelper::get($plan, 'user_input') === 'yes') {
760 - $plan['subscription_amount'] = ArrayHelper::get($data, $name . '_custom_' . $data[$name]);
761 - $plan['subscription_amount'] = $plan['subscription_amount'] ?: 0;
1120 + $plan['subscription_amount'] = $this->getCustomSubscriptionAmount($data, $name, $planKey);
762 1121 }
763 1122
764 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 + }
765 1131
766 1132 if (ArrayHelper::get($plan, 'has_signup_fee') === 'yes' && ArrayHelper::get($plan, 'signup_fee')) {
767 1133 $amount += PaymentHelper::convertToCents($plan['signup_fee']);
768 1134 }