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