PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 1.3.20 1.3.19 trunk All 48 releases
← All changes | app/Helpers/CartHelper.php +131 -104 1.5.2 → 1.6.5 View file →
@@ -203,9 +203,9 @@
203 203
204 204 private static function excludeFreeShippingPhysicalItems(array &$items, array &$physicalItems): void
205 205 {
206 206 foreach ($physicalItems as $key => $item) {
207 - if (static::itemHasFreeShipping($item)) {
207 + if (self::itemHasFreeShipping($item)) {
208 208 $items[$key]['shipping_charge'] = 0;
209 209 $items[$key]['itemwise_shipping_charge'] = 0;
210 210 unset($physicalItems[$key]);
211 211 }
@@ -214,36 +214,62 @@
214 214
215 215 public static function calculateShippingMethodCharge(ShippingMethod $method, ?array $items = null, $returnType = 'amount')
216 216 {
217 217 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;
218 + static $lastFingerprint = null;
219 + static $products = null;
220 + static $shippingClasses = null;
221 +
222 + // Per-call locals: $physicalItems/$isAllDigital are rebuilt fresh from $items on every
223 + // call (via CheckoutService below), and $totalItemPrice/$totalQuantity/
224 + // $totalShippingCharge/$maxShippingCharge are accumulated fresh in the per-item
225 + // annotation loop below, so none of them may persist across calls — only the
226 + // $products/$shippingClasses DB lookups above are worth caching per request.
227 + $totalItemPrice = 0;
228 + $totalQuantity = 0;
229 + $physicalItems = [];
230 + $isAllDigital = false;
231 + $maxShippingCharge = 0;
232 + $totalShippingCharge = 0;
226 233 $isUsingCart = false;
227 234
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 235 if ($items === null) {
242 236 $isUsingCart = true;
243 237 $items = static::getCart()->cart_data ?? [];
244 238 }
245 239
240 + // Fingerprint the resolved method + items so a same-request call with changed
241 + // cart items (e.g. an item added/removed after ShippingModule::handleItemsChanges
242 + // re-runs this calc) is never mistaken for a repeat of the previous call. Must be
243 + // computed from the RESOLVED $items (post null → cart fallback above), not the raw
244 + // argument, otherwise a null-argument call would fingerprint differently from the
245 + // cart data it resolves to. Fields: id/object_id/variation_id, quantity, line_total,
246 + // free_shipping, post_id, unit_price, discount_total.
247 + $fingerprint = md5(serialize([
248 + $method->id,
249 + array_map(function ($item) {
250 + return [
251 + Arr::get($item, 'id', Arr::get($item, 'object_id', Arr::get($item, 'variation_id'))),
252 + Arr::get($item, 'quantity'),
253 + Arr::get($item, 'line_total'),
254 + self::itemHasFreeShipping($item) ? 'yes' : 'no',
255 + Arr::get($item, 'post_id'),
256 + Arr::get($item, 'unit_price'),
257 + Arr::get($item, 'discount_total'),
258 + ];
259 + }, $items),
260 + ]));
261 +
262 + // Reset the cached-lookup guard when the method/items fingerprint changes to prevent
263 + // stale $products/$shippingClasses from a previous call in the same request (replaces
264 + // the old $lastMethodId check, which missed same-method-id calls made with different
265 + // items). The per-call locals above are already reinitialized on every call, so only
266 + // the "once" guard needs resetting here.
267 + if ($lastFingerprint !== $fingerprint) {
268 + $onceCalculated = false;
269 + $lastFingerprint = $fingerprint;
270 + }
271 +
246 272 if ($method->type === 'free_shipping') {
247 273 if ($returnType === 'items') {
248 274 if ($items === null) {
249 275 $items = static::getCart()->cart_data ?? [];
@@ -259,10 +285,8 @@
259 285 }
260 286 return 0;
261 287 }
262 288
263 - $totalItemWiseShippingCharge = 0;
264 -
265 289 $cartCheckoutService = new CheckoutService($items);
266 290 $isAllDigital = $cartCheckoutService->isAllDigital();
267 291 $physicalItems = $cartCheckoutService->physicalItems;
268 292
@@ -268,8 +292,23 @@
268 292
269 293 // Exclude only physical items marked for free shipping from charge calculation.
270 294 static::excludeFreeShippingPhysicalItems($items, $physicalItems);
271 295
296 + // No shipping is charged for all-digital carts or when every physical item has free shipping.
297 + if ($isAllDigital || empty($physicalItems)) {
298 + if ($returnType === 'items') {
299 + foreach ($items as $key => $item) {
300 + $items[$key]['shipping_charge'] = 0;
301 + $items[$key]['itemwise_shipping_charge'] = 0;
302 + }
303 + return [
304 + 'items' => $items,
305 + 'shipping_amount' => 0
306 + ];
307 + }
308 + return 0;
309 + }
310 +
272 311 if (!$onceCalculated) {
273 312 $onceCalculated = true;
274 313 $productIds = array_unique(array_column($physicalItems, 'post_id'));
275 314 $products = Product::query()->whereIn('ID', $productIds)
@@ -281,58 +320,47 @@
281 320 return !empty($item);
282 321 })->toArray();
283 322
284 323 $shippingClasses = ShippingClass::query()->whereIn('id', $shippingClassIds)->get()->keyBy('id');
324 + }
285 325
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;
326 + // Per-item annotation must run on every call, not gated behind $onceCalculated:
327 + // $physicalItems is always re-derived fresh from the current $items argument above, so
328 + // a cache-hit call still needs its own $items populated with shipping_charge and its
329 + // own totals accumulated. Only the $products/$shippingClasses DB lookups above are
330 + // safe to reuse across calls in the same request.
331 + foreach ($physicalItems as $key => &$item) {
332 + $totalQuantity += Arr::get($item, 'quantity');
333 + $totalItemPrice += (Arr::get($item, 'quantity') * Arr::get($item, 'unit_price')) - Arr::get($item, 'discount_total');
334 + $itemShippingCharge = 0;
290 335
291 - $product = $products->get(Arr::get($item, 'post_id'));
336 + $product = $products->get(Arr::get($item, 'post_id'));
292 337
293 338
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 - );
339 + if (isset($product->detail->other_info['shipping_class'])) {
340 + // shipping_class is null or not defined
341 + $shippingClass = $shippingClasses->get(
342 + $product->detail->other_info['shipping_class']
343 + );
299 344
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 - }
345 + if ($shippingClass) {
346 + $perItem = $shippingClass->per_item;
347 + $factor = empty($perItem) ? 1 : Arr::get($item, 'quantity');
348 + if ($shippingClass->type === 'percentage') {
349 + $itemShippingCharge = ($shippingClass->cost / 100) * Arr::get($item, 'unit_price') * $factor;
350 + } else {
351 + $itemShippingCharge = Helper::toCent($shippingClass->cost) * $factor;
308 352 }
309 353 }
310 - $item['shipping_charge'] = $itemShippingCharge;
311 - $totalShippingCharge += $itemShippingCharge;
312 -
313 - $items[$key] = $item;
314 - $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
315 354 }
355 + $item['shipping_charge'] = $itemShippingCharge;
356 + $totalShippingCharge += $itemShippingCharge;
316 357
317 - $totalItemWiseShippingCharge = $totalShippingCharge;
358 + $items[$key] = $item;
359 + $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
318 360 }
361 + unset($item);
319 362
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 363 $settings = Arr::wrap($method->settings);
336 364 $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
337 365 $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
338 366
@@ -405,37 +433,32 @@
405 433 }
406 434
407 435 $shippingMethodAmount = (int)round($shippingMethodAmount);
408 436
409 - $remainingShippingMethodAmount = ($shippingMethodAmount - $totalItemWiseShippingCharge);
437 + $remainingShippingMethodAmount = ($shippingMethodAmount - $totalShippingCharge);
410 438
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);
439 + // Distribution must run on every call (not gated behind a "once" flag): $physicalItems
440 + // above is always re-derived fresh from the current $items argument regardless of the
441 + // $onceCalculated cache, so a cached call still needs its own $items populated with
442 + // itemwise_shipping_charge — a stale "already distributed" flag would leave a freshly
443 + // passed-in items array with missing/zero shares even though the fingerprint matched.
444 + $totalLineTotal = array_sum(array_column($physicalItems, 'line_total'));
445 + $distributed = 0;
446 + $itemCount = count($physicalItems);
447 + $lastIndex = array_key_last($physicalItems);
417 448
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 - }
449 + foreach ($physicalItems as $key => $item) {
450 + if ($key === $lastIndex) {
451 + // Last item takes the exact remainder — per-item rounding must never
452 + // change the total the customer is charged for shipping.
453 + $share = (int) round($remainingShippingMethodAmount - $distributed);
454 + } elseif ($totalLineTotal > 0) {
455 + $share = (int) round(($item['line_total'] / $totalLineTotal) * $remainingShippingMethodAmount);
425 456 } 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 - }
457 + $share = (int) round($remainingShippingMethodAmount / $itemCount);
431 458 }
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 - }
459 + $items[$key]['itemwise_shipping_charge'] = $share;
460 + $distributed += $share;
438 461 }
439 462
440 463 if ($isUsingCart) {
441 464 $cart = CartHelper::getCart();
@@ -676,18 +699,31 @@
676 699
677 700 $methodOnlyAmount = $methodBaseRate;
678 701 $distributed = 0;
679 702 $itemCount = count($physicalItems);
703 +
704 + // The last physical item overall (last item of the last group, in traversal order)
705 + // absorbs the exact remainder — per-item rounding must never change the total
706 + // the customer is charged for shipping.
707 + $lastGroupKey = array_key_last($groups);
708 + $lastItemIdx = ($lastGroupKey !== null && !empty($groups[$lastGroupKey]['items']))
709 + ? array_key_last($groups[$lastGroupKey]['items'])
710 + : null;
711 +
680 712 foreach ($groups as $groupKey => &$group) {
681 713 $groupItems = $group['items'];
682 714 foreach ($groupItems as $idx => &$gItem) {
683 - if ($totalLineTotal > 0) {
684 - $share = (Arr::get($gItem, 'line_total', 0) / $totalLineTotal) * $methodOnlyAmount;
715 + if ($groupKey === $lastGroupKey && $idx === $lastItemIdx) {
716 + $share = (int) round($methodOnlyAmount - $distributed);
717 + } elseif ($totalLineTotal > 0) {
718 + $share = (int) round((Arr::get($gItem, 'line_total', 0) / $totalLineTotal) * $methodOnlyAmount);
685 719 } else {
686 - $share = $itemCount > 0 ? ($methodOnlyAmount / $itemCount) : 0;
720 + $share = $itemCount > 0 ? (int) round($methodOnlyAmount / $itemCount) : 0;
687 721 }
688 - $share = round($share, 2);
689 - $gItem['itemwise_shipping_charge'] = ceil($share) + Arr::get($gItem, 'shipping_charge', 0);
722 + // itemwise_shipping_charge carries only the proportional base-rate share.
723 + // The class surcharge stays exclusively in shipping_charge (set above) so it
724 + // isn't taxed twice by TaxCalculator::getShippingTax(), which sums both fields.
725 + $gItem['itemwise_shipping_charge'] = $share;
690 726 $distributed += $share;
691 727 }
692 728 unset($gItem);
693 729 $group['items'] = $groupItems;
@@ -694,18 +730,8 @@
694 730 $group['amount'] = $group['class_charge'];
695 731 }
696 732 unset($group);
697 733
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 734 // Merge group items back into cartItems
709 735 foreach ($groups as $group) {
710 736 foreach ($group['keys'] as $i => $key) {
711 737 if (isset($group['items'][$i])) {
@@ -764,9 +790,10 @@
764 790 {
765 791 if (is_user_logged_in()) {
766 792 $wpUser = wp_get_current_user();
767 793 $cart->user_id = get_current_user_id();
768 - $customer = Customer::query()->where('email', wp_get_current_user()->user_email)->first();
794 + // The cart belongs to the account's linked customer, not to whichever record holds its email.
795 + $customer = Customer::query()->where('user_id', $wpUser->ID)->orderBy('id', 'ASC')->first();
769 796 if ($customer) {
770 797 $cart->customer_id = $customer->id;
771 798 }
772 799 $cart->email = $wpUser->user_email;