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 +315 -26 6.2.13 → 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,10 +637,20 @@
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 656 // SECURITY (FINDING-22): clamp a user-supplied quantity to a non-negative integer so a
@@ -553,8 +739,56 @@
553 739 }
554 740 return $total;
555 741 }
556 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 +
557 791 public function getPaymentType()
558 792 {
559 793 return count($this->getSubscriptionItems()) ? 'subscription' : 'product'; // return value product|subscription|donation
560 794 }
@@ -585,9 +819,9 @@
585 819 foreach ($subscriptionInputs as $subscriptionInput) {
586 820 $name = ArrayHelper::get($subscriptionInput, 'attributes.name');
587 821 $quantity = $this->getQuantity($name);
588 822
589 - if (!$name || !isset($data[$name]) || $quantity === 0) {
823 + if (!$name || $quantity === 0) {
590 824 continue;
591 825 }
592 826
593 827 $label = ArrayHelper::get($subscriptionInput, 'settings.label', $name);
@@ -593,17 +827,22 @@
593 827 $label = ArrayHelper::get($subscriptionInput, 'settings.label', $name);
594 828
595 829 $subscriptionOptions = ArrayHelper::get($subscriptionInput, 'settings.subscription_options');
596 830
597 - $plan = $subscriptionOptions[$data[$name]];
831 + $planKey = $this->resolvePlanKey($subscriptionInput, $subscriptionOptions);
598 832
833 + if (is_null($planKey)) {
834 + continue;
835 + }
836 +
837 + $plan = ArrayHelper::get($subscriptionOptions, $planKey);
838 +
599 839 if (!$plan) {
600 840 continue;
601 841 }
602 842
603 843 if (ArrayHelper::get($plan, 'user_input') === 'yes') {
604 - $plan['subscription_amount'] = ArrayHelper::get($data, $name . '_custom_' . $data[$name]);
605 - $plan['subscription_amount'] = $plan['subscription_amount'] ?: 0;
844 + $plan['subscription_amount'] = $this->getCustomSubscriptionAmount($data, $name, $planKey);
606 845 }
607 846
608 847 $noTrial = ArrayHelper::get($plan, 'has_trial_days') === 'no' ||
609 848 !ArrayHelper::get($plan, 'trial_days');
@@ -727,8 +966,17 @@
727 966
728 967 $submission = Submission::find($meta->response_id);
729 968
730 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 +
731 979 return $submission;
732 980 }
733 981
734 982 return false;
@@ -733,8 +981,23 @@
733 981
734 982 return false;
735 983 }
736 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 +
737 1000 private function insertOrderItems($items, $existing = false)
738 1001 {
739 1002 if (!$existing) {
740 1003 foreach ($items as $item) {
@@ -788,8 +1051,22 @@
788 1051
789 1052 return true;
790 1053 }
791 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 +
792 1069 private function validateSubscriptionInputs()
793 1070 {
794 1071 $subscriptionInputs = $this->subscriptionInputs;
795 1072 if (!$subscriptionInputs) {
@@ -806,16 +1083,22 @@
806 1083 if (!$quantity) {
807 1084 continue;
808 1085 }
809 1086
810 - if (!$name || !isset($data[$name])) {
1087 + if (!$name) {
811 1088 continue;
812 1089 }
813 1090
814 1091 $subscriptionOptions = ArrayHelper::get($subscriptionInput, 'settings.subscription_options');
815 1092
816 - $plan = $subscriptionOptions[$data[$name]];
1093 + $planKey = $this->resolvePlanKey($subscriptionInput, $subscriptionOptions);
817 1094
1095 + if (is_null($planKey)) {
1096 + continue;
1097 + }
1098 +
1099 + $plan = ArrayHelper::get($subscriptionOptions, $planKey);
1100 +
818 1101 if (!$plan) {
819 1102 continue;
820 1103 }
821 1104
@@ -833,13 +1116,19 @@
833 1116
834 1117 // We have bill times 1 so we have to remove this and push to hooked inputs and later merged to payment inputs
835 1118
836 1119 if (ArrayHelper::get($plan, 'user_input') === 'yes') {
837 - $plan['subscription_amount'] = ArrayHelper::get($data, $name . '_custom_' . $data[$name]);
838 - $plan['subscription_amount'] = $plan['subscription_amount'] ?: 0;
1120 + $plan['subscription_amount'] = $this->getCustomSubscriptionAmount($data, $name, $planKey);
839 1121 }
840 1122
841 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 + }
842 1131
843 1132 if (ArrayHelper::get($plan, 'has_signup_fee') === 'yes' && ArrayHelper::get($plan, 'signup_fee')) {
844 1133 $amount += PaymentHelper::convertToCents($plan['signup_fee']);
845 1134 }