PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
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 1.3.21 All 51 releases
← All changes | app/Helpers/CartHelper.php +271 -96 1.3.26 → 1.7.1 View file →
@@ -16,8 +16,64 @@
16 16 use FluentCart\Framework\Support\Arr;
17 17
18 18 class CartHelper
19 19 {
20 + const MAX_QUANTITY = 100000;
21 +
22 + const QUANTITY_HARD_LIMIT = 10000000;
23 +
24 + /**
25 + * The ceiling is what keeps `unit_price * quantity` inside PHP's integer range —
26 + * without one the product overflows to a float and casts back to a wrapped total.
27 + * Cart updates send a signed delta, so those allow the negative side too.
28 + */
29 + public static function validateQuantity($quantity, $isDelta = false)
30 + {
31 + if (!is_numeric($quantity)) {
32 + return static::invalidQuantityError();
33 + }
34 +
35 + $max = static::maxQuantity();
36 + $value = (float)$quantity;
37 + $min = $isDelta ? -$max : 1;
38 +
39 + if ($value != floor($value) || $value < $min || $value > $max) {
40 + return static::invalidQuantityError();
41 + }
42 +
43 + return null;
44 + }
45 +
46 + public static function maxQuantity()
47 + {
48 + /**
49 + * Filter the highest quantity a single cart line accepts.
50 + *
51 + * Return a whole number of one or more. The value is a safety ceiling, not just
52 + * a storefront preference: it is what keeps a line's price multiplication inside
53 + * PHP's integer range. A value that cannot serve that purpose is ignored — a
54 + * non-numeric or fractional value falls back to the default, and anything above
55 + * QUANTITY_HARD_LIMIT is capped there.
56 + *
57 + * @param int $maxQuantity Highest accepted quantity for one cart line.
58 + */
59 + $max = apply_filters('fluent_cart/cart/max_quantity', self::MAX_QUANTITY);
60 +
61 + if (!is_numeric($max) || (float)$max != floor((float)$max) || $max < 1) {
62 + return self::MAX_QUANTITY;
63 + }
64 +
65 + return (int)min($max, self::QUANTITY_HARD_LIMIT);
66 + }
67 +
68 + private static function invalidQuantityError()
69 + {
70 + /* translators: %d: the highest quantity a single cart line accepts. */
71 + $message = __('Please enter a product quantity between 1 and %d.', 'fluent-cart');
72 +
73 + return new \WP_Error('invalid_cart_quantity', sprintf($message, static::maxQuantity()));
74 + }
75 +
20 76 public static function getCart($hash = null, $create = false)
21 77 {
22 78 return CartResource::get([
23 79 'hash' => $hash ?? App::request()->get(Helper::INSTANT_CHECKOUT_URL_PARAM),
@@ -62,9 +118,13 @@
62 118 [
63 119 'selected' => $variation->id
64 120 ]
65 121 ),
66 - 'variation_type' => $variation['product_detail']['variation_type'],
122 + // Property access lazy-loads when callers didn't eager-load
123 + // product_detail (instant-checkout, cart update endpoint) so
124 + // CartRenderer can read variation_type to toggle the
125 + // variant-title-hidden class on simple products.
126 + 'variation_type' => $variation->product_detail ? $variation->product_detail->variation_type : '',
67 127 'is_custom' => false,
68 128 ], $variation->toArray());
69 129
70 130 $cartItem = Arr::only($data, [
@@ -91,8 +151,21 @@
91 151 ]);
92 152
93 153 // $cartItem['shipping_charge'] = $shippingCharge;
94 154
155 + // Snapshot the variant's attribute set (pa_* + third-party) into
156 + // other_info so cart, checkout and the resulting order item all carry a
157 + // frozen attribute map that survives later attribute-library renames.
158 + $otherInfo = Arr::get($cartItem, 'other_info', []);
159 + if (!is_array($otherInfo)) {
160 + $otherInfo = [];
161 + }
162 + $otherInfo['item_attributes'] = AttributeHelper::getProductItemAttributes(
163 + $variation->id,
164 + $variation->post_id
165 + );
166 + $cartItem['other_info'] = $otherInfo;
167 +
95 168 return $cartItem;
96 169 }
97 170
98 171 public static function generateCartItemCustomItem(array $variation, $quantity = 1): array
@@ -178,39 +251,81 @@
178 251
179 252 return ($shippingClass->cost * 100) * $factor;
180 253 }
181 254
255 + private static function itemHasFreeShipping($item): bool
256 + {
257 + return Arr::get($item, 'other_info.free_shipping', 'no') === 'yes';
258 + }
259 +
260 + private static function excludeFreeShippingPhysicalItems(array &$items, array &$physicalItems): void
261 + {
262 + foreach ($physicalItems as $key => $item) {
263 + if (self::itemHasFreeShipping($item)) {
264 + $items[$key]['shipping_charge'] = 0;
265 + $items[$key]['itemwise_shipping_charge'] = 0;
266 + unset($physicalItems[$key]);
267 + }
268 + }
269 + }
270 +
182 271 public static function calculateShippingMethodCharge(ShippingMethod $method, ?array $items = null, $returnType = 'amount')
183 272 {
184 273 static $onceCalculated = false;
185 - static $onceDistributed = false;
186 - static $totalItemPrice = 0;
187 - static $totalQuantity = 0;
188 - static $physicalItems = [];
189 - static $isAllDigital = false;
190 - static $maxShippingCharge = 0;
191 - static $totalShippingCharge = 0;
192 - static $lastMethodId = null;
274 + static $lastFingerprint = null;
275 + static $products = null;
276 + static $shippingClasses = null;
277 +
278 + // Per-call locals: $physicalItems/$isAllDigital are rebuilt fresh from $items on every
279 + // call (via CheckoutService below), and $totalItemPrice/$totalQuantity/
280 + // $totalShippingCharge/$maxShippingCharge are accumulated fresh in the per-item
281 + // annotation loop below, so none of them may persist across calls — only the
282 + // $products/$shippingClasses DB lookups above are worth caching per request.
283 + $totalItemPrice = 0;
284 + $totalQuantity = 0;
285 + $physicalItems = [];
286 + $isAllDigital = false;
287 + $maxShippingCharge = 0;
288 + $totalShippingCharge = 0;
193 289 $isUsingCart = false;
194 290
195 - // Reset statics when called with a different method to prevent stale state
196 - if ($lastMethodId !== $method->id) {
197 - $onceCalculated = false;
198 - $onceDistributed = false;
199 - $totalItemPrice = 0;
200 - $totalQuantity = 0;
201 - $physicalItems = [];
202 - $isAllDigital = false;
203 - $maxShippingCharge = 0;
204 - $totalShippingCharge = 0;
205 - $lastMethodId = $method->id;
206 - }
207 -
208 291 if ($items === null) {
209 292 $isUsingCart = true;
210 293 $items = static::getCart()->cart_data ?? [];
211 294 }
212 295
296 + // Fingerprint the resolved method + items so a same-request call with changed
297 + // cart items (e.g. an item added/removed after ShippingModule::handleItemsChanges
298 + // re-runs this calc) is never mistaken for a repeat of the previous call. Must be
299 + // computed from the RESOLVED $items (post null → cart fallback above), not the raw
300 + // argument, otherwise a null-argument call would fingerprint differently from the
301 + // cart data it resolves to. Fields: id/object_id/variation_id, quantity, line_total,
302 + // free_shipping, post_id, unit_price, discount_total.
303 + $fingerprint = md5(serialize([
304 + $method->id,
305 + array_map(function ($item) {
306 + return [
307 + Arr::get($item, 'id', Arr::get($item, 'object_id', Arr::get($item, 'variation_id'))),
308 + Arr::get($item, 'quantity'),
309 + Arr::get($item, 'line_total'),
310 + self::itemHasFreeShipping($item) ? 'yes' : 'no',
311 + Arr::get($item, 'post_id'),
312 + Arr::get($item, 'unit_price'),
313 + Arr::get($item, 'discount_total'),
314 + ];
315 + }, $items),
316 + ]));
317 +
318 + // Reset the cached-lookup guard when the method/items fingerprint changes to prevent
319 + // stale $products/$shippingClasses from a previous call in the same request (replaces
320 + // the old $lastMethodId check, which missed same-method-id calls made with different
321 + // items). The per-call locals above are already reinitialized on every call, so only
322 + // the "once" guard needs resetting here.
323 + if ($lastFingerprint !== $fingerprint) {
324 + $onceCalculated = false;
325 + $lastFingerprint = $fingerprint;
326 + }
327 +
213 328 if ($method->type === 'free_shipping') {
214 329 if ($returnType === 'items') {
215 330 if ($items === null) {
216 331 $items = static::getCart()->cart_data ?? [];
@@ -226,14 +341,30 @@
226 341 }
227 342 return 0;
228 343 }
229 344
230 - $totalItemWiseShippingCharge = 0;
231 -
232 345 $cartCheckoutService = new CheckoutService($items);
233 346 $isAllDigital = $cartCheckoutService->isAllDigital();
234 347 $physicalItems = $cartCheckoutService->physicalItems;
235 348
349 + // Exclude only physical items marked for free shipping from charge calculation.
350 + static::excludeFreeShippingPhysicalItems($items, $physicalItems);
351 +
352 + // No shipping is charged for all-digital carts or when every physical item has free shipping.
353 + if ($isAllDigital || empty($physicalItems)) {
354 + if ($returnType === 'items') {
355 + foreach ($items as $key => $item) {
356 + $items[$key]['shipping_charge'] = 0;
357 + $items[$key]['itemwise_shipping_charge'] = 0;
358 + }
359 + return [
360 + 'items' => $items,
361 + 'shipping_amount' => 0
362 + ];
363 + }
364 + return 0;
365 + }
366 +
236 367 if (!$onceCalculated) {
237 368 $onceCalculated = true;
238 369 $productIds = array_unique(array_column($physicalItems, 'post_id'));
239 370 $products = Product::query()->whereIn('ID', $productIds)
@@ -245,47 +376,47 @@
245 376 return !empty($item);
246 377 })->toArray();
247 378
248 379 $shippingClasses = ShippingClass::query()->whereIn('id', $shippingClassIds)->get()->keyBy('id');
380 + }
249 381
250 - foreach ($physicalItems as $key => &$item) {
251 - $totalQuantity += Arr::get($item, 'quantity');
252 - $totalItemPrice += (Arr::get($item, 'quantity') * Arr::get($item, 'unit_price')) - Arr::get($item, 'discount_total');
253 - $itemShippingCharge = 0;
382 + // Per-item annotation must run on every call, not gated behind $onceCalculated:
383 + // $physicalItems is always re-derived fresh from the current $items argument above, so
384 + // a cache-hit call still needs its own $items populated with shipping_charge and its
385 + // own totals accumulated. Only the $products/$shippingClasses DB lookups above are
386 + // safe to reuse across calls in the same request.
387 + foreach ($physicalItems as $key => &$item) {
388 + $totalQuantity += Arr::get($item, 'quantity');
389 + $totalItemPrice += (Arr::get($item, 'quantity') * Arr::get($item, 'unit_price')) - Arr::get($item, 'discount_total');
390 + $itemShippingCharge = 0;
254 391
255 - $product = $products->get(Arr::get($item, 'post_id'));
392 + $product = $products->get(Arr::get($item, 'post_id'));
256 393
257 394
258 - if (isset($product->detail->other_info['shipping_class'])) {
259 - // shipping_class is null or not defined
260 - $shippingClass = $shippingClasses->get(
261 - $product->detail->other_info['shipping_class']
262 - );
395 + if (isset($product->detail->other_info['shipping_class'])) {
396 + // shipping_class is null or not defined
397 + $shippingClass = $shippingClasses->get(
398 + $product->detail->other_info['shipping_class']
399 + );
263 400
264 - if ($shippingClass) {
265 - $perItem = $shippingClass->per_item;
266 - $factor = empty($perItem) ? 1 : Arr::get($item, 'quantity');
267 - if ($shippingClass->type === 'percentage') {
268 - $itemShippingCharge = ($shippingClass->cost / 100) * Arr::get($item, 'unit_price') * $factor;
269 - } else {
270 - $itemShippingCharge = Helper::toCent($shippingClass->cost) * $factor;
271 - }
401 + if ($shippingClass) {
402 + $perItem = $shippingClass->per_item;
403 + $factor = empty($perItem) ? 1 : Arr::get($item, 'quantity');
404 + if ($shippingClass->type === 'percentage') {
405 + $itemShippingCharge = ($shippingClass->cost / 100) * Arr::get($item, 'unit_price') * $factor;
406 + } else {
407 + $itemShippingCharge = Helper::toCent($shippingClass->cost) * $factor;
272 408 }
273 409 }
274 - $item['shipping_charge'] = $itemShippingCharge;
275 - $totalShippingCharge += $itemShippingCharge;
276 -
277 - $items[$key] = $item;
278 - $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
279 410 }
411 + $item['shipping_charge'] = $itemShippingCharge;
412 + $totalShippingCharge += $itemShippingCharge;
280 413
281 - $totalItemWiseShippingCharge = $totalShippingCharge;
414 + $items[$key] = $item;
415 + $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
282 416 }
417 + unset($item);
283 418
284 - if ($isAllDigital) {
285 - return 0;
286 - }
287 -
288 419 $settings = Arr::wrap($method->settings);
289 420 $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
290 421 $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
291 422
@@ -356,37 +487,34 @@
356 487 } else {
357 488 $shippingMethodAmount += $totalShippingCharge;
358 489 }
359 490
360 - $remainingShippingMethodAmount = ($shippingMethodAmount - $totalItemWiseShippingCharge);
491 + $shippingMethodAmount = (int)round($shippingMethodAmount);
361 492
362 - if (!$onceDistributed) {
363 - $onceDistributed = true;
364 - $totalLineTotal = array_sum(array_column($physicalItems, 'line_total'));
365 - $distributed = 0;
366 - $totalRemain = $remainingShippingMethodAmount;
367 - $itemCount = count($physicalItems);
493 + $remainingShippingMethodAmount = ($shippingMethodAmount - $totalShippingCharge);
368 494
369 - if ($totalLineTotal > 0) {
370 - foreach ($physicalItems as $key => &$item) {
371 - $share = ($item['line_total'] / $totalLineTotal) * $remainingShippingMethodAmount;
372 - $share = round($share, 2);
373 - $items[$key]['itemwise_shipping_charge'] = ceil($share);
374 - $distributed += $share;
375 - }
495 + // Distribution must run on every call (not gated behind a "once" flag): $physicalItems
496 + // above is always re-derived fresh from the current $items argument regardless of the
497 + // $onceCalculated cache, so a cached call still needs its own $items populated with
498 + // itemwise_shipping_charge — a stale "already distributed" flag would leave a freshly
499 + // passed-in items array with missing/zero shares even though the fingerprint matched.
500 + $totalLineTotal = array_sum(array_column($physicalItems, 'line_total'));
501 + $distributed = 0;
502 + $itemCount = count($physicalItems);
503 + $lastIndex = array_key_last($physicalItems);
504 +
505 + foreach ($physicalItems as $key => $item) {
506 + if ($key === $lastIndex) {
507 + // Last item takes the exact remainder — per-item rounding must never
508 + // change the total the customer is charged for shipping.
509 + $share = (int) round($remainingShippingMethodAmount - $distributed);
510 + } elseif ($totalLineTotal > 0) {
511 + $share = (int) round(($item['line_total'] / $totalLineTotal) * $remainingShippingMethodAmount);
376 512 } else {
377 - $equalShare = round($remainingShippingMethodAmount / $itemCount, 2);
378 - foreach ($physicalItems as $key => &$item) {
379 - $items[$key]['itemwise_shipping_charge'] = ceil($equalShare);
380 - $distributed += $equalShare;
381 - }
513 + $share = (int) round($remainingShippingMethodAmount / $itemCount);
382 514 }
383 -
384 - $diff = round($totalRemain - $distributed, 2);
385 - if ($diff != 0) {
386 - $lastIndex = array_key_last($physicalItems);
387 - $items[$lastIndex]['itemwise_shipping_charge'] = ceil($diff);
388 - }
515 + $items[$key]['itemwise_shipping_charge'] = $share;
516 + $distributed += $share;
389 517 }
390 518
391 519 if ($isUsingCart) {
392 520 $cart = CartHelper::getCart();
@@ -422,10 +550,16 @@
422 550 */
423 551 public static function calculateShippingByProfile($shippingMethodId, $cartItems, $country, $state = null, $returnType = 'amount')
424 552 {
425 553 $cartCheckoutService = new CheckoutService($cartItems);
554 + $isAllDigital = $cartCheckoutService->isAllDigital();
555 + $physicalItems = $cartCheckoutService->physicalItems;
426 556
427 - if ($cartCheckoutService->isAllDigital()) {
557 + // Exclude only physical items marked for free shipping from profile-based charges.
558 + static::excludeFreeShippingPhysicalItems($cartItems, $physicalItems);
559 +
560 + // No shipping is charged for all-digital carts or when every physical item has free shipping.
561 + if ($isAllDigital || empty($physicalItems)) {
428 562 if ($returnType === 'items') {
429 563 foreach ($cartItems as $key => $item) {
430 564 $cartItems[$key]['shipping_charge'] = 0;
431 565 $cartItems[$key]['itemwise_shipping_charge'] = 0;
@@ -434,10 +568,8 @@
434 568 }
435 569 return 0;
436 570 }
437 571
438 - $physicalItems = $cartCheckoutService->physicalItems;
439 -
440 572 // Load products with details
441 573 $productIds = array_unique(array_column($physicalItems, 'post_id'));
442 574 $products = Product::query()->whereIn('ID', $productIds)
443 575 ->with(['detail'])
@@ -623,18 +755,31 @@
623 755
624 756 $methodOnlyAmount = $methodBaseRate;
625 757 $distributed = 0;
626 758 $itemCount = count($physicalItems);
759 +
760 + // The last physical item overall (last item of the last group, in traversal order)
761 + // absorbs the exact remainder — per-item rounding must never change the total
762 + // the customer is charged for shipping.
763 + $lastGroupKey = array_key_last($groups);
764 + $lastItemIdx = ($lastGroupKey !== null && !empty($groups[$lastGroupKey]['items']))
765 + ? array_key_last($groups[$lastGroupKey]['items'])
766 + : null;
767 +
627 768 foreach ($groups as $groupKey => &$group) {
628 769 $groupItems = $group['items'];
629 770 foreach ($groupItems as $idx => &$gItem) {
630 - if ($totalLineTotal > 0) {
631 - $share = (Arr::get($gItem, 'line_total', 0) / $totalLineTotal) * $methodOnlyAmount;
771 + if ($groupKey === $lastGroupKey && $idx === $lastItemIdx) {
772 + $share = (int) round($methodOnlyAmount - $distributed);
773 + } elseif ($totalLineTotal > 0) {
774 + $share = (int) round((Arr::get($gItem, 'line_total', 0) / $totalLineTotal) * $methodOnlyAmount);
632 775 } else {
633 - $share = $itemCount > 0 ? ($methodOnlyAmount / $itemCount) : 0;
776 + $share = $itemCount > 0 ? (int) round($methodOnlyAmount / $itemCount) : 0;
634 777 }
635 - $share = round($share, 2);
636 - $gItem['itemwise_shipping_charge'] = ceil($share) + Arr::get($gItem, 'shipping_charge', 0);
778 + // itemwise_shipping_charge carries only the proportional base-rate share.
779 + // The class surcharge stays exclusively in shipping_charge (set above) so it
780 + // isn't taxed twice by TaxCalculator::getShippingTax(), which sums both fields.
781 + $gItem['itemwise_shipping_charge'] = $share;
637 782 $distributed += $share;
638 783 }
639 784 unset($gItem);
640 785 $group['items'] = $groupItems;
@@ -641,18 +786,8 @@
641 786 $group['amount'] = $group['class_charge'];
642 787 }
643 788 unset($group);
644 789
645 - // Correct rounding difference on last physical item
646 - $diff = round($methodOnlyAmount - $distributed, 2);
647 - if ($diff != 0) {
648 - $lastGroupKey = array_key_last($groups);
649 - if ($lastGroupKey !== null && !empty($groups[$lastGroupKey]['items'])) {
650 - $lastItemIdx = array_key_last($groups[$lastGroupKey]['items']);
651 - $groups[$lastGroupKey]['items'][$lastItemIdx]['itemwise_shipping_charge'] += ceil($diff);
652 - }
653 - }
654 -
655 790 // Merge group items back into cartItems
656 791 foreach ($groups as $group) {
657 792 foreach ($group['keys'] as $i => $key) {
658 793 if (isset($group['items'][$i])) {
@@ -711,9 +846,10 @@
711 846 {
712 847 if (is_user_logged_in()) {
713 848 $wpUser = wp_get_current_user();
714 849 $cart->user_id = get_current_user_id();
715 - $customer = Customer::query()->where('email', wp_get_current_user()->user_email)->first();
850 + // The cart belongs to the account's linked customer, not to whichever record holds its email.
851 + $customer = Customer::query()->where('user_id', $wpUser->ID)->orderBy('id', 'ASC')->first();
716 852 if ($customer) {
717 853 $cart->customer_id = $customer->id;
718 854 }
719 855 $cart->email = $wpUser->user_email;
@@ -806,6 +942,45 @@
806 942 if (empty($variationId)) {
807 943 return false;
808 944 }
809 945 return $variationId;
946 + }
947 +
948 + /**
949 + * @param \FluentCart\App\Models\Cart $cart
950 + * @param array|\WP_Error $methods
951 + * @param string|int|null $currentSelectedId
952 + * @return string|int|null
953 + */
954 + public static function resolveAutoSelectShippingMethod($cart, $methods, $currentSelectedId)
955 + {
956 + if ($currentSelectedId || empty($methods) || is_wp_error($methods) || count($methods) !== 1) {
957 + return $currentSelectedId;
958 + }
959 +
960 + $method = $methods[0];
961 +
962 + $shouldAutoSelect = apply_filters('fluent_cart/shipping/auto_select_single_method', true, [
963 + 'cart' => $cart,
964 + 'method' => $method,
965 + ]);
966 +
967 + if (!$shouldAutoSelect) {
968 + return $currentSelectedId;
969 + }
970 +
971 + $charge = static::calculateShippingMethodCharge($method, $cart->cart_data);
972 +
973 + $cart->checkout_data = array_merge(
974 + (array) $cart->checkout_data,
975 + [
976 + 'shipping_data' => [
977 + 'shipping_method_id' => $method->id,
978 + 'shipping_charge' => is_array($charge) ? Arr::get($charge, 'shipping_amount', 0) : $charge,
979 + ],
980 + ]
981 + );
982 + $cart->save();
983 +
984 + return $method->id;
810 985 }
811 986 }