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 +200 -104 1.5.0 → 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),
@@ -95,8 +151,21 @@
95 151 ]);
96 152
97 153 // $cartItem['shipping_charge'] = $shippingCharge;
98 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 +
99 168 return $cartItem;
100 169 }
101 170
102 171 public static function generateCartItemCustomItem(array $variation, $quantity = 1): array
@@ -190,9 +259,9 @@
190 259
191 260 private static function excludeFreeShippingPhysicalItems(array &$items, array &$physicalItems): void
192 261 {
193 262 foreach ($physicalItems as $key => $item) {
194 - if (static::itemHasFreeShipping($item)) {
263 + if (self::itemHasFreeShipping($item)) {
195 264 $items[$key]['shipping_charge'] = 0;
196 265 $items[$key]['itemwise_shipping_charge'] = 0;
197 266 unset($physicalItems[$key]);
198 267 }
@@ -201,36 +270,62 @@
201 270
202 271 public static function calculateShippingMethodCharge(ShippingMethod $method, ?array $items = null, $returnType = 'amount')
203 272 {
204 273 static $onceCalculated = false;
205 - static $onceDistributed = false;
206 - static $totalItemPrice = 0;
207 - static $totalQuantity = 0;
208 - static $physicalItems = [];
209 - static $isAllDigital = false;
210 - static $maxShippingCharge = 0;
211 - static $totalShippingCharge = 0;
212 - 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;
213 289 $isUsingCart = false;
214 290
215 - // Reset statics when called with a different method to prevent stale state
216 - if ($lastMethodId !== $method->id) {
217 - $onceCalculated = false;
218 - $onceDistributed = false;
219 - $totalItemPrice = 0;
220 - $totalQuantity = 0;
221 - $physicalItems = [];
222 - $isAllDigital = false;
223 - $maxShippingCharge = 0;
224 - $totalShippingCharge = 0;
225 - $lastMethodId = $method->id;
226 - }
227 -
228 291 if ($items === null) {
229 292 $isUsingCart = true;
230 293 $items = static::getCart()->cart_data ?? [];
231 294 }
232 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 +
233 328 if ($method->type === 'free_shipping') {
234 329 if ($returnType === 'items') {
235 330 if ($items === null) {
236 331 $items = static::getCart()->cart_data ?? [];
@@ -246,10 +341,8 @@
246 341 }
247 342 return 0;
248 343 }
249 344
250 - $totalItemWiseShippingCharge = 0;
251 -
252 345 $cartCheckoutService = new CheckoutService($items);
253 346 $isAllDigital = $cartCheckoutService->isAllDigital();
254 347 $physicalItems = $cartCheckoutService->physicalItems;
255 348
@@ -255,8 +348,23 @@
255 348
256 349 // Exclude only physical items marked for free shipping from charge calculation.
257 350 static::excludeFreeShippingPhysicalItems($items, $physicalItems);
258 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 +
259 367 if (!$onceCalculated) {
260 368 $onceCalculated = true;
261 369 $productIds = array_unique(array_column($physicalItems, 'post_id'));
262 370 $products = Product::query()->whereIn('ID', $productIds)
@@ -268,58 +376,47 @@
268 376 return !empty($item);
269 377 })->toArray();
270 378
271 379 $shippingClasses = ShippingClass::query()->whereIn('id', $shippingClassIds)->get()->keyBy('id');
380 + }
272 381
273 - foreach ($physicalItems as $key => &$item) {
274 - $totalQuantity += Arr::get($item, 'quantity');
275 - $totalItemPrice += (Arr::get($item, 'quantity') * Arr::get($item, 'unit_price')) - Arr::get($item, 'discount_total');
276 - $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;
277 391
278 - $product = $products->get(Arr::get($item, 'post_id'));
392 + $product = $products->get(Arr::get($item, 'post_id'));
279 393
280 394
281 - if (isset($product->detail->other_info['shipping_class'])) {
282 - // shipping_class is null or not defined
283 - $shippingClass = $shippingClasses->get(
284 - $product->detail->other_info['shipping_class']
285 - );
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 + );
286 400
287 - if ($shippingClass) {
288 - $perItem = $shippingClass->per_item;
289 - $factor = empty($perItem) ? 1 : Arr::get($item, 'quantity');
290 - if ($shippingClass->type === 'percentage') {
291 - $itemShippingCharge = ($shippingClass->cost / 100) * Arr::get($item, 'unit_price') * $factor;
292 - } else {
293 - $itemShippingCharge = Helper::toCent($shippingClass->cost) * $factor;
294 - }
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;
295 408 }
296 409 }
297 - $item['shipping_charge'] = $itemShippingCharge;
298 - $totalShippingCharge += $itemShippingCharge;
299 -
300 - $items[$key] = $item;
301 - $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
302 410 }
411 + $item['shipping_charge'] = $itemShippingCharge;
412 + $totalShippingCharge += $itemShippingCharge;
303 413
304 - $totalItemWiseShippingCharge = $totalShippingCharge;
414 + $items[$key] = $item;
415 + $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
305 416 }
417 + unset($item);
306 418
307 - // No shipping is charged for all-digital carts or when every physical item has free shipping.
308 - if ($isAllDigital || empty($physicalItems)) {
309 - if ($returnType === 'items') {
310 - foreach ($items as $key => $item) {
311 - $items[$key]['shipping_charge'] = 0;
312 - $items[$key]['itemwise_shipping_charge'] = 0;
313 - }
314 - return [
315 - 'items' => $items,
316 - 'shipping_amount' => 0
317 - ];
318 - }
319 - return 0;
320 - }
321 -
322 419 $settings = Arr::wrap($method->settings);
323 420 $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
324 421 $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
325 422
@@ -392,37 +489,32 @@
392 489 }
393 490
394 491 $shippingMethodAmount = (int)round($shippingMethodAmount);
395 492
396 - $remainingShippingMethodAmount = ($shippingMethodAmount - $totalItemWiseShippingCharge);
493 + $remainingShippingMethodAmount = ($shippingMethodAmount - $totalShippingCharge);
397 494
398 - if (!$onceDistributed) {
399 - $onceDistributed = true;
400 - $totalLineTotal = array_sum(array_column($physicalItems, 'line_total'));
401 - $distributed = 0;
402 - $totalRemain = $remainingShippingMethodAmount;
403 - $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);
404 504
405 - if ($totalLineTotal > 0) {
406 - foreach ($physicalItems as $key => &$item) {
407 - $share = ($item['line_total'] / $totalLineTotal) * $remainingShippingMethodAmount;
408 - $share = round($share, 2);
409 - $items[$key]['itemwise_shipping_charge'] = ceil($share);
410 - $distributed += $share;
411 - }
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);
412 512 } else {
413 - $equalShare = round($remainingShippingMethodAmount / $itemCount, 2);
414 - foreach ($physicalItems as $key => &$item) {
415 - $items[$key]['itemwise_shipping_charge'] = ceil($equalShare);
416 - $distributed += $equalShare;
417 - }
513 + $share = (int) round($remainingShippingMethodAmount / $itemCount);
418 514 }
419 -
420 - $diff = round($totalRemain - $distributed, 2);
421 - if ($diff != 0) {
422 - $lastIndex = array_key_last($physicalItems);
423 - $items[$lastIndex]['itemwise_shipping_charge'] = ceil($diff);
424 - }
515 + $items[$key]['itemwise_shipping_charge'] = $share;
516 + $distributed += $share;
425 517 }
426 518
427 519 if ($isUsingCart) {
428 520 $cart = CartHelper::getCart();
@@ -663,18 +755,31 @@
663 755
664 756 $methodOnlyAmount = $methodBaseRate;
665 757 $distributed = 0;
666 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 +
667 768 foreach ($groups as $groupKey => &$group) {
668 769 $groupItems = $group['items'];
669 770 foreach ($groupItems as $idx => &$gItem) {
670 - if ($totalLineTotal > 0) {
671 - $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);
672 775 } else {
673 - $share = $itemCount > 0 ? ($methodOnlyAmount / $itemCount) : 0;
776 + $share = $itemCount > 0 ? (int) round($methodOnlyAmount / $itemCount) : 0;
674 777 }
675 - $share = round($share, 2);
676 - $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;
677 782 $distributed += $share;
678 783 }
679 784 unset($gItem);
680 785 $group['items'] = $groupItems;
@@ -681,18 +786,8 @@
681 786 $group['amount'] = $group['class_charge'];
682 787 }
683 788 unset($group);
684 789
685 - // Correct rounding difference on last physical item
686 - $diff = round($methodOnlyAmount - $distributed, 2);
687 - if ($diff != 0) {
688 - $lastGroupKey = array_key_last($groups);
689 - if ($lastGroupKey !== null && !empty($groups[$lastGroupKey]['items'])) {
690 - $lastItemIdx = array_key_last($groups[$lastGroupKey]['items']);
691 - $groups[$lastGroupKey]['items'][$lastItemIdx]['itemwise_shipping_charge'] += ceil($diff);
692 - }
693 - }
694 -
695 790 // Merge group items back into cartItems
696 791 foreach ($groups as $group) {
697 792 foreach ($group['keys'] as $i => $key) {
698 793 if (isset($group['items'][$i])) {
@@ -751,9 +846,10 @@
751 846 {
752 847 if (is_user_logged_in()) {
753 848 $wpUser = wp_get_current_user();
754 849 $cart->user_id = get_current_user_id();
755 - $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();
756 852 if ($customer) {
757 853 $cart->customer_id = $customer->id;
758 854 }
759 855 $cart->email = $wpUser->user_email;