| @@ -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 | } |