PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.8.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.8.0
1.8.0 1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 All 52 releases
← All changes | app/Helpers/CheckoutProcessor.php +134 -11 1.6.3 → 1.8.0 View file →
@@ -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,12 +968,28 @@
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
976 + // A shipping pricing rule discounts this order's postage only; renewals bill the full charge.
977 + $shippingDiscount = $isPhysicalProduct ? (int)Arr::get($this->args, 'shipping_discount', 0) : 0;
978 + if ($shippingDiscount > 0) {
979 + $renewalTotal = $recurringTotal + $shippingDiscount;
980 + // A fully discounted charge left its shipping tax out above as well.
981 + if ($shippingCharge <= 0) {
982 + $storeTaxBehavior = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
983 + if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1)) {
984 + $renewalTotal += (int)Arr::get($this->args, 'shipping_tax', 0);
985 + }
986 + }
987 +
988 + $subscriptionPricing = $this->billRenewalsFullShipping($subscriptionPricing, $recurringTotal, $renewalTotal, $shippingDiscount);
989 + $recurringTotal = $renewalTotal;
990 + }
991 +
933 992 // removable upon discussion
934 993 $subscriptionItem = [
935 994 'product_id' => $item['post_id'],
936 995 'current_payment_method' => Arr::get($this->orderData, 'payment_method'),
@@ -951,13 +1010,8 @@
951 1010 'variation_type' => Arr::get($item, 'other_info.variation_type', '')
952 1011 ]
953 1012 ];
954 1013
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 1014 $subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
961 1015 $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
962 1016
963 1017 $collectionMethod = apply_filters('fluent_cart/subscription_collection_method_' . $paymentMethod, $this->determineCollectionMethod());
@@ -981,8 +1035,44 @@
981 1035
982 1036 $this->subscriptionData = $subscriptionData;
983 1037 }
984 1038
1039 + /**
1040 + * Raise the per-cycle price to the full postage while keeping the first payment.
1041 + *
1042 + * A gateway takes the signup fee alone during a trial, otherwise one cycle plus the
1043 + * signup fee. So without a trial the fee absorbs the difference, or the first cycle
1044 + * becomes a simulated trial, the same way a first-cycle coupon is expressed.
1045 + *
1046 + * @param array $pricing From convertToSubscriptionFormat(), built on the discounted postage.
1047 + * @param int $firstCycleTotal Recurring total on the discounted postage, in cents.
1048 + * @param int $renewalTotal Recurring total on the full postage, in cents.
1049 + * @param int $shippingDiscount In cents.
1050 + * @return array
1051 + */
1052 + private function billRenewalsFullShipping(array $pricing, $firstCycleTotal, $renewalTotal, $shippingDiscount)
1053 + {
1054 + $pricing['recurring_amount'] = (int)$pricing['recurring_amount'] + $shippingDiscount;
1055 +
1056 + if ((int)$pricing['trial_days'] > 0) {
1057 + return $pricing;
1058 + }
1059 +
1060 + $extra = $renewalTotal - $firstCycleTotal;
1061 + $signupFee = (int)$pricing['signup_fee'];
1062 +
1063 + if ($signupFee >= $extra) {
1064 + $pricing['signup_fee'] = $signupFee - $extra;
1065 + return $pricing;
1066 + }
1067 +
1068 + $pricing['trial_days'] = Helper::calculateAdjustedTrialDaysForInterval(0, $pricing['billing_interval']);
1069 + $pricing['is_trial_days_simulated'] = 'yes';
1070 + $pricing['signup_fee'] = $firstCycleTotal + $signupFee;
1071 +
1072 + return $pricing;
1073 + }
1074 +
985 1075 private function determineCollectionMethod(): string
986 1076 {
987 1077 if (SubscriptionManagementMode::isStoreManaged()) {
988 1078 $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
@@ -1069,8 +1159,9 @@
1069 1159 'user_tz' => Arr::get($this->args, 'user_tz', ''),
1070 1160 'create_account_after_paid' => Arr::get($this->args, 'create_account_after_paid', 'no'),
1071 1161 'shipping_method_id' => Arr::get($this->args, 'shipping_method_id', 0),
1072 1162 'shipping_method_title' => Arr::get($this->args, 'shipping_method_title', ''),
1163 + 'shipping_discount' => (int) Arr::get($this->args, 'shipping_discount', 0),
1073 1164 'prorate_credit' => $this->prorateCreditTotal,
1074 1165 'upgrade_discount' => $this->upgradeDiscountTotal,
1075 1166 ],
1076 1167 ];
@@ -1103,8 +1194,36 @@
1103 1194 + $estimatedTaxTotal
1104 1195 + $estimatedShippingTax;
1105 1196
1106 1197 $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0;
1198 +
1199 + /**
1200 + * Filter the prepared order data before it is used for order creation.
1201 + *
1202 + * This runs after FluentCart calculates totals, so plugins can adjust
1203 + * currency, rate, totals, config, mode, or any other order field before
1204 + * the order model, transaction, and subscription are derived from it.
1205 + *
1206 + * @param array $orderData Prepared order data array.
1207 + * @param array $context {
1208 + * Additional context for the filter.
1209 + *
1210 + * @type array $items Formatted order items with prices and quantities.
1211 + * @type array $args Checkout arguments: customer data, payment method,
1212 + * shipping, tax, coupons, fees, and IP data.
1213 + * }
1214 + */
1215 + $orderData = apply_filters('fluent_cart/checkout/order_data', $orderData, [
1216 + 'items' => $this->formattedIOrderItems,
1217 + 'args' => $this->args,
1218 + ]);
1219 +
1220 + if (!$this->isSafeAmount(Arr::get($orderData, 'total_amount', 0))) {
1221 + $this->validationError = $this->unsafeAmountError();
1222 +
1223 + return;
1224 + }
1225 +
1107 1226 $this->orderData = $orderData;
1108 1227 }
1109 1228
1110 1229 private function syncFeeItems()
@@ -1229,8 +1348,9 @@
1229 1348 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1230 1349 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1231 1350 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1232 1351 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1352 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1233 1353
1234 1354 // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1235 1355 $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1236 1356 $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
@@ -1270,10 +1390,13 @@
1270 1390 }
1271 1391 } else {
1272 1392 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1273 1393
1274 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1275 - $recurringAmount -= $totalDiscount; // as now discount applied on recurring amount
1394 + // A recurring coupon discounts every cycle, so the per-cycle price itself
1395 + // is lower — the first cycle is not cheaper than the ones after it and
1396 + // must not be expressed as a trial.
1397 + if ($recurringDiscount > 0) {
1398 + $recurringAmount -= $recurringDiscount;
1276 1399 }
1277 1400
1278 1401 if ($firstCycleCost < $recurringAmount) {
1279 1402 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);