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 +187 -104 1.5.2 → 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),
@@ -203,9 +259,9 @@
203 259
204 260 private static function excludeFreeShippingPhysicalItems(array &$items, array &$physicalItems): void
205 261 {
206 262 foreach ($physicalItems as $key => $item) {
207 - if (static::itemHasFreeShipping($item)) {
263 + if (self::itemHasFreeShipping($item)) {
208 264 $items[$key]['shipping_charge'] = 0;
209 265 $items[$key]['itemwise_shipping_charge'] = 0;
210 266 unset($physicalItems[$key]);
211 267 }
@@ -214,36 +270,62 @@
214 270
215 271 public static function calculateShippingMethodCharge(ShippingMethod $method, ?array $items = null, $returnType = 'amount')
216 272 {
217 273 static $onceCalculated = false;
218 - static $onceDistributed = false;
219 - static $totalItemPrice = 0;
220 - static $totalQuantity = 0;
221 - static $physicalItems = [];
222 - static $isAllDigital = false;
223 - static $maxShippingCharge = 0;
224 - static $totalShippingCharge = 0;
225 - 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;
226 289 $isUsingCart = false;
227 290
228 - // Reset statics when called with a different method to prevent stale state
229 - if ($lastMethodId !== $method->id) {
230 - $onceCalculated = false;
231 - $onceDistributed = false;
232 - $totalItemPrice = 0;
233 - $totalQuantity = 0;
234 - $physicalItems = [];
235 - $isAllDigital = false;
236 - $maxShippingCharge = 0;
237 - $totalShippingCharge = 0;
238 - $lastMethodId = $method->id;
239 - }
240 -
241 291 if ($items === null) {
242 292 $isUsingCart = true;
243 293 $items = static::getCart()->cart_data ?? [];
244 294 }
245 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 +
246 328 if ($method->type === 'free_shipping') {
247 329 if ($returnType === 'items') {
248 330 if ($items === null) {
249 331 $items = static::getCart()->cart_data ?? [];
@@ -259,10 +341,8 @@
259 341 }
260 342 return 0;
261 343 }
262 344
263 - $totalItemWiseShippingCharge = 0;
264 -
265 345 $cartCheckoutService = new CheckoutService($items);
266 346 $isAllDigital = $cartCheckoutService->isAllDigital();
267 347 $physicalItems = $cartCheckoutService->physicalItems;
268 348
@@ -268,8 +348,23 @@
268 348
269 349 // Exclude only physical items marked for free shipping from charge calculation.
270 350 static::excludeFreeShippingPhysicalItems($items, $physicalItems);
271 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 +
272 367 if (!$onceCalculated) {
273 368 $onceCalculated = true;
274 369 $productIds = array_unique(array_column($physicalItems, 'post_id'));
275 370 $products = Product::query()->whereIn('ID', $productIds)
@@ -281,58 +376,47 @@
281 376 return !empty($item);
282 377 })->toArray();
283 378
284 379 $shippingClasses = ShippingClass::query()->whereIn('id', $shippingClassIds)->get()->keyBy('id');
380 + }
285 381
286 - foreach ($physicalItems as $key => &$item) {
287 - $totalQuantity += Arr::get($item, 'quantity');
288 - $totalItemPrice += (Arr::get($item, 'quantity') * Arr::get($item, 'unit_price')) - Arr::get($item, 'discount_total');
289 - $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;
290 391
291 - $product = $products->get(Arr::get($item, 'post_id'));
392 + $product = $products->get(Arr::get($item, 'post_id'));
292 393
293 394
294 - if (isset($product->detail->other_info['shipping_class'])) {
295 - // shipping_class is null or not defined
296 - $shippingClass = $shippingClasses->get(
297 - $product->detail->other_info['shipping_class']
298 - );
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 + );
299 400
300 - if ($shippingClass) {
301 - $perItem = $shippingClass->per_item;
302 - $factor = empty($perItem) ? 1 : Arr::get($item, 'quantity');
303 - if ($shippingClass->type === 'percentage') {
304 - $itemShippingCharge = ($shippingClass->cost / 100) * Arr::get($item, 'unit_price') * $factor;
305 - } else {
306 - $itemShippingCharge = Helper::toCent($shippingClass->cost) * $factor;
307 - }
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;
308 408 }
309 409 }
310 - $item['shipping_charge'] = $itemShippingCharge;
311 - $totalShippingCharge += $itemShippingCharge;
312 -
313 - $items[$key] = $item;
314 - $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
315 410 }
411 + $item['shipping_charge'] = $itemShippingCharge;
412 + $totalShippingCharge += $itemShippingCharge;
316 413
317 - $totalItemWiseShippingCharge = $totalShippingCharge;
414 + $items[$key] = $item;
415 + $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
318 416 }
417 + unset($item);
319 418
320 - // No shipping is charged for all-digital carts or when every physical item has free shipping.
321 - if ($isAllDigital || empty($physicalItems)) {
322 - if ($returnType === 'items') {
323 - foreach ($items as $key => $item) {
324 - $items[$key]['shipping_charge'] = 0;
325 - $items[$key]['itemwise_shipping_charge'] = 0;
326 - }
327 - return [
328 - 'items' => $items,
329 - 'shipping_amount' => 0
330 - ];
331 - }
332 - return 0;
333 - }
334 -
335 419 $settings = Arr::wrap($method->settings);
336 420 $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
337 421 $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
338 422
@@ -405,37 +489,32 @@
405 489 }
406 490
407 491 $shippingMethodAmount = (int)round($shippingMethodAmount);
408 492
409 - $remainingShippingMethodAmount = ($shippingMethodAmount - $totalItemWiseShippingCharge);
493 + $remainingShippingMethodAmount = ($shippingMethodAmount - $totalShippingCharge);
410 494
411 - if (!$onceDistributed) {
412 - $onceDistributed = true;
413 - $totalLineTotal = array_sum(array_column($physicalItems, 'line_total'));
414 - $distributed = 0;
415 - $totalRemain = $remainingShippingMethodAmount;
416 - $itemCount = count($physicalItems);
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);
417 504
418 - if ($totalLineTotal > 0) {
419 - foreach ($physicalItems as $key => &$item) {
420 - $share = ($item['line_total'] / $totalLineTotal) * $remainingShippingMethodAmount;
421 - $share = round($share, 2);
422 - $items[$key]['itemwise_shipping_charge'] = ceil($share);
423 - $distributed += $share;
424 - }
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);
425 512 } else {
426 - $equalShare = round($remainingShippingMethodAmount / $itemCount, 2);
427 - foreach ($physicalItems as $key => &$item) {
428 - $items[$key]['itemwise_shipping_charge'] = ceil($equalShare);
429 - $distributed += $equalShare;
430 - }
513 + $share = (int) round($remainingShippingMethodAmount / $itemCount);
431 514 }
432 -
433 - $diff = round($totalRemain - $distributed, 2);
434 - if ($diff != 0) {
435 - $lastIndex = array_key_last($physicalItems);
436 - $items[$lastIndex]['itemwise_shipping_charge'] = ceil($diff);
437 - }
515 + $items[$key]['itemwise_shipping_charge'] = $share;
516 + $distributed += $share;
438 517 }
439 518
440 519 if ($isUsingCart) {
441 520 $cart = CartHelper::getCart();
@@ -676,18 +755,31 @@
676 755
677 756 $methodOnlyAmount = $methodBaseRate;
678 757 $distributed = 0;
679 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 +
680 768 foreach ($groups as $groupKey => &$group) {
681 769 $groupItems = $group['items'];
682 770 foreach ($groupItems as $idx => &$gItem) {
683 - if ($totalLineTotal > 0) {
684 - $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);
685 775 } else {
686 - $share = $itemCount > 0 ? ($methodOnlyAmount / $itemCount) : 0;
776 + $share = $itemCount > 0 ? (int) round($methodOnlyAmount / $itemCount) : 0;
687 777 }
688 - $share = round($share, 2);
689 - $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;
690 782 $distributed += $share;
691 783 }
692 784 unset($gItem);
693 785 $group['items'] = $groupItems;
@@ -694,18 +786,8 @@
694 786 $group['amount'] = $group['class_charge'];
695 787 }
696 788 unset($group);
697 789
698 - // Correct rounding difference on last physical item
699 - $diff = round($methodOnlyAmount - $distributed, 2);
700 - if ($diff != 0) {
701 - $lastGroupKey = array_key_last($groups);
702 - if ($lastGroupKey !== null && !empty($groups[$lastGroupKey]['items'])) {
703 - $lastItemIdx = array_key_last($groups[$lastGroupKey]['items']);
704 - $groups[$lastGroupKey]['items'][$lastItemIdx]['itemwise_shipping_charge'] += ceil($diff);
705 - }
706 - }
707 -
708 790 // Merge group items back into cartItems
709 791 foreach ($groups as $group) {
710 792 foreach ($group['keys'] as $i => $key) {
711 793 if (isset($group['items'][$i])) {
@@ -764,9 +846,10 @@
764 846 {
765 847 if (is_user_logged_in()) {
766 848 $wpUser = wp_get_current_user();
767 849 $cart->user_id = get_current_user_id();
768 - $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();
769 852 if ($customer) {
770 853 $cart->customer_id = $customer->id;
771 854 }
772 855 $cart->email = $wpUser->user_email;