| @@ -22,8 +22,9 @@ | ||
| 22 | 22 | |
| 23 | 23 | // Raw Data |
| 24 | 24 | private $cartItems = []; |
| 25 | 25 | private $args = []; |
| 26 | + private $validationError; | |
| 26 | 27 | |
| 27 | 28 | // Order Related Data |
| 28 | 29 | private $formattedIOrderItems = []; |
| 29 | 30 | private $orderData = []; |
| @@ -62,14 +63,43 @@ | ||
| 62 | 63 | |
| 63 | 64 | private function prepareData() |
| 64 | 65 | { |
| 65 | 66 | $this->prepareOrderItems(); |
| 67 | + if ($this->validationError) { | |
| 68 | + return; | |
| 69 | + } | |
| 70 | + | |
| 66 | 71 | $this->prepareOrderData(); |
| 72 | + if ($this->validationError) { | |
| 73 | + return; | |
| 74 | + } | |
| 75 | + | |
| 67 | 76 | $this->prepareSubscriptionData(); |
| 68 | 77 | } |
| 69 | 78 | |
| 79 | + /** | |
| 80 | + * (int) on an out-of-range float wraps, and the max(0, ...) clamps downstream turn a | |
| 81 | + * wrapped amount into a free but payable order. Refuse the checkout instead. | |
| 82 | + */ | |
| 83 | + private function isSafeAmount($value) | |
| 84 | + { | |
| 85 | + return is_numeric($value) && is_finite((float)$value) && abs((float)$value) < (float)PHP_INT_MAX; | |
| 86 | + } | |
| 87 | + | |
| 88 | + private function unsafeAmountError() | |
| 89 | + { | |
| 90 | + return new \WP_Error( | |
| 91 | + 'invalid_order_total', | |
| 92 | + __('The order total is too large to process. Please reduce the quantity.', 'fluent-cart') | |
| 93 | + ); | |
| 94 | + } | |
| 95 | + | |
| 70 | 96 | public function createDraftOrder($prevOrder = null) |
| 71 | 97 | { |
| 98 | + if ($this->validationError) { | |
| 99 | + return $this->validationError; | |
| 100 | + } | |
| 101 | + | |
| 72 | 102 | if ($prevOrder) { |
| 73 | 103 | return $this->getAdjustedOrder($prevOrder); |
| 74 | 104 | } |
| 75 | 105 | |
| @@ -486,19 +516,25 @@ | ||
| 486 | 516 | ->where('order_id', $this->orderModel->id) |
| 487 | 517 | ->first(); |
| 488 | 518 | |
| 489 | 519 | if ($existingTransaction) { |
| 520 | + $meta = $existingTransaction->meta ?: []; | |
| 521 | + | |
| 490 | 522 | // Retry vs duplicate for gateway idempotency (PaymentInstance::getIdempotencySeed): |
| 491 | 523 | // re-submitting a pending transaction is a duplicate (keep attempt -> gateway |
| 492 | 524 | // dedupes); re-submitting a FAILED one is a retry (bump attempt -> fresh seed, |
| 493 | 525 | // never answered with the failed attempt's cached gateway response). |
| 494 | - $attempt = (int) Arr::get($existingTransaction->meta ?: [], 'payment_attempt', 0); | |
| 526 | + $attempt = (int) Arr::get($meta, 'payment_attempt', 0); | |
| 495 | 527 | if ($existingTransaction->status === Status::PAYMENT_FAILED) { |
| 496 | 528 | $attempt++; |
| 497 | 529 | } |
| 530 | + | |
| 531 | + // The gateway object prepared last time (a Paddle transaction, a PayPal | |
| 532 | + // order) is kept so the gateway can reuse it instead of creating another. | |
| 498 | 533 | if ($attempt) { |
| 499 | - $transactionData['meta'] = ['payment_attempt' => $attempt]; | |
| 534 | + $meta['payment_attempt'] = $attempt; | |
| 500 | 535 | } |
| 536 | + $transactionData['meta'] = $meta; | |
| 501 | 537 | |
| 502 | 538 | $existingTransaction->fill($transactionData); |
| 503 | 539 | $existingTransaction->save(); |
| 504 | 540 | $this->transactionModel = $existingTransaction; |
| @@ -616,9 +652,16 @@ | ||
| 616 | 652 | |
| 617 | 653 | $discountTotal = (int)Arr::get($cartItem, 'manual_discount', 0) + (int)Arr::get($cartItem, 'coupon_discount', 0); |
| 618 | 654 | $shippingCharge = (int)Arr::get($cartItem, 'shipping_charge', 0); |
| 619 | 655 | |
| 620 | - $subtotal = (int) Arr::get($cartItem, 'subtotal', $unitPrice * $quantity); | |
| 656 | + $rawSubtotal = Arr::get($cartItem, 'subtotal', $unitPrice * $quantity); | |
| 657 | + if (!$this->isSafeAmount($rawSubtotal)) { | |
| 658 | + $this->validationError = $this->unsafeAmountError(); | |
| 659 | + | |
| 660 | + return; | |
| 661 | + } | |
| 662 | + | |
| 663 | + $subtotal = (int) $rawSubtotal; | |
| 621 | 664 | $args = Arr::get($cartItem, 'other_info', []); |
| 622 | 665 | $paymentType = Arr::get($args, 'payment_type', 'default'); |
| 623 | 666 | |
| 624 | 667 | $postTitle = Arr::get($cartItem, 'product_title', ''); |
| @@ -925,9 +968,9 @@ | ||
| 925 | 968 | 'line_meta' => Arr::get($item, 'line_meta', []), |
| 926 | 969 | 'signup_fee' => $signupFee, |
| 927 | 970 | 'signup_fee_tax' => $signupFeeTax, |
| 928 | 971 | 'first_iteration_tax' => $firstIterationTax, |
| 929 | - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'), | |
| 972 | + 'recurring_discount' => $recurringDiscountAmount, | |
| 930 | 973 | 'total_discount' => $discountTotal |
| 931 | 974 | ]); |
| 932 | 975 | |
| 933 | 976 | // removable upon discussion |
| @@ -951,13 +994,8 @@ | ||
| 951 | 994 | 'variation_type' => Arr::get($item, 'other_info.variation_type', '') |
| 952 | 995 | ] |
| 953 | 996 | ]; |
| 954 | 997 | |
| 955 | - // if recurring coupon is applied, we need to subtract the total discount from the recurring total | |
| 956 | - if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') { | |
| 957 | - $subscriptionItem['recurring_total'] -= $discountTotal; | |
| 958 | - } | |
| 959 | - | |
| 960 | 998 | $subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem); |
| 961 | 999 | $paymentMethod = Arr::get($this->orderData, 'payment_method', ''); |
| 962 | 1000 | |
| 963 | 1001 | $collectionMethod = apply_filters('fluent_cart/subscription_collection_method_' . $paymentMethod, $this->determineCollectionMethod()); |
| @@ -1125,8 +1163,14 @@ | ||
| 1125 | 1163 | 'items' => $this->formattedIOrderItems, |
| 1126 | 1164 | 'args' => $this->args, |
| 1127 | 1165 | ]); |
| 1128 | 1166 | |
| 1167 | + if (!$this->isSafeAmount(Arr::get($orderData, 'total_amount', 0))) { | |
| 1168 | + $this->validationError = $this->unsafeAmountError(); | |
| 1169 | + | |
| 1170 | + return; | |
| 1171 | + } | |
| 1172 | + | |
| 1129 | 1173 | $this->orderData = $orderData; |
| 1130 | 1174 | } |
| 1131 | 1175 | |
| 1132 | 1176 | private function syncFeeItems() |
| @@ -1251,8 +1295,9 @@ | ||
| 1251 | 1295 | $signupFee = (int)($inputData['signup_fee'] ?? 0); |
| 1252 | 1296 | $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0); |
| 1253 | 1297 | $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0); |
| 1254 | 1298 | $totalDiscount = (int)($inputData['total_discount'] ?? 0); |
| 1299 | + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0); | |
| 1255 | 1300 | |
| 1256 | 1301 | // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts) |
| 1257 | 1302 | $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0); |
| 1258 | 1303 | $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false); |
| @@ -1292,10 +1337,13 @@ | ||
| 1292 | 1337 | } |
| 1293 | 1338 | } else { |
| 1294 | 1339 | $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount; |
| 1295 | 1340 | |
| 1296 | - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') { | |
| 1297 | - $recurringAmount -= $totalDiscount; // as now discount applied on recurring amount | |
| 1341 | + // A recurring coupon discounts every cycle, so the per-cycle price itself | |
| 1342 | + // is lower — the first cycle is not cheaper than the ones after it and | |
| 1343 | + // must not be expressed as a trial. | |
| 1344 | + if ($recurringDiscount > 0) { | |
| 1345 | + $recurringAmount -= $recurringDiscount; | |
| 1298 | 1346 | } |
| 1299 | 1347 | |
| 1300 | 1348 | if ($firstCycleCost < $recurringAmount) { |
| 1301 | 1349 | $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval); |