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