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.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 1.3.20 1.3.19 All 49 releases
← All changes | app/Helpers/CartHelper.php +312 -214 1.3.23 → 1.6.5 View file →
@@ -62,9 +62,13 @@
62 62 [
63 63 'selected' => $variation->id
64 64 ]
65 65 ),
66 - 'variation_type' => $variation['product_detail']['variation_type'],
66 + // Property access lazy-loads when callers didn't eager-load
67 + // product_detail (instant-checkout, cart update endpoint) so
68 + // CartRenderer can read variation_type to toggle the
69 + // variant-title-hidden class on simple products.
70 + 'variation_type' => $variation->product_detail ? $variation->product_detail->variation_type : '',
67 71 'is_custom' => false,
68 72 ], $variation->toArray());
69 73
70 74 $cartItem = Arr::only($data, [
@@ -91,8 +95,21 @@
91 95 ]);
92 96
93 97 // $cartItem['shipping_charge'] = $shippingCharge;
94 98
99 + // Snapshot the variant's attribute set (pa_* + third-party) into
100 + // other_info so cart, checkout and the resulting order item all carry a
101 + // frozen attribute map that survives later attribute-library renames.
102 + $otherInfo = Arr::get($cartItem, 'other_info', []);
103 + if (!is_array($otherInfo)) {
104 + $otherInfo = [];
105 + }
106 + $otherInfo['item_attributes'] = AttributeHelper::getProductItemAttributes(
107 + $variation->id,
108 + $variation->post_id
109 + );
110 + $cartItem['other_info'] = $otherInfo;
111 +
95 112 return $cartItem;
96 113 }
97 114
98 115 public static function generateCartItemCustomItem(array $variation, $quantity = 1): array
@@ -178,39 +195,81 @@
178 195
179 196 return ($shippingClass->cost * 100) * $factor;
180 197 }
181 198
199 + private static function itemHasFreeShipping($item): bool
200 + {
201 + return Arr::get($item, 'other_info.free_shipping', 'no') === 'yes';
202 + }
203 +
204 + private static function excludeFreeShippingPhysicalItems(array &$items, array &$physicalItems): void
205 + {
206 + foreach ($physicalItems as $key => $item) {
207 + if (self::itemHasFreeShipping($item)) {
208 + $items[$key]['shipping_charge'] = 0;
209 + $items[$key]['itemwise_shipping_charge'] = 0;
210 + unset($physicalItems[$key]);
211 + }
212 + }
213 + }
214 +
182 215 public static function calculateShippingMethodCharge(ShippingMethod $method, ?array $items = null, $returnType = 'amount')
183 216 {
184 217 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;
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;
193 233 $isUsingCart = false;
194 234
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 235 if ($items === null) {
209 236 $isUsingCart = true;
210 237 $items = static::getCart()->cart_data ?? [];
211 238 }
212 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 +
213 272 if ($method->type === 'free_shipping') {
214 273 if ($returnType === 'items') {
215 274 if ($items === null) {
216 275 $items = static::getCart()->cart_data ?? [];
@@ -226,14 +285,30 @@
226 285 }
227 286 return 0;
228 287 }
229 288
230 - $totalItemWiseShippingCharge = 0;
231 -
232 289 $cartCheckoutService = new CheckoutService($items);
233 290 $isAllDigital = $cartCheckoutService->isAllDigital();
234 291 $physicalItems = $cartCheckoutService->physicalItems;
235 292
293 + // Exclude only physical items marked for free shipping from charge calculation.
294 + static::excludeFreeShippingPhysicalItems($items, $physicalItems);
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 +
236 311 if (!$onceCalculated) {
237 312 $onceCalculated = true;
238 313 $productIds = array_unique(array_column($physicalItems, 'post_id'));
239 314 $products = Product::query()->whereIn('ID', $productIds)
@@ -245,47 +320,47 @@
245 320 return !empty($item);
246 321 })->toArray();
247 322
248 323 $shippingClasses = ShippingClass::query()->whereIn('id', $shippingClassIds)->get()->keyBy('id');
324 + }
249 325
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;
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;
254 335
255 - $product = $products->get(Arr::get($item, 'post_id'));
336 + $product = $products->get(Arr::get($item, 'post_id'));
256 337
257 338
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 - );
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 + );
263 344
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 - }
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;
272 352 }
273 353 }
274 - $item['shipping_charge'] = $itemShippingCharge;
275 - $totalShippingCharge += $itemShippingCharge;
276 -
277 - $items[$key] = $item;
278 - $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
279 354 }
355 + $item['shipping_charge'] = $itemShippingCharge;
356 + $totalShippingCharge += $itemShippingCharge;
280 357
281 - $totalItemWiseShippingCharge = $totalShippingCharge;
358 + $items[$key] = $item;
359 + $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
282 360 }
361 + unset($item);
283 362
284 - if ($isAllDigital) {
285 - return 0;
286 - }
287 -
288 363 $settings = Arr::wrap($method->settings);
289 364 $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
290 365 $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
291 366
@@ -356,37 +431,34 @@
356 431 } else {
357 432 $shippingMethodAmount += $totalShippingCharge;
358 433 }
359 434
360 - $remainingShippingMethodAmount = ($shippingMethodAmount - $totalItemWiseShippingCharge);
435 + $shippingMethodAmount = (int)round($shippingMethodAmount);
361 436
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);
437 + $remainingShippingMethodAmount = ($shippingMethodAmount - $totalShippingCharge);
368 438
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 - }
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);
448 +
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);
376 456 } 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 - }
457 + $share = (int) round($remainingShippingMethodAmount / $itemCount);
382 458 }
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 - }
459 + $items[$key]['itemwise_shipping_charge'] = $share;
460 + $distributed += $share;
389 461 }
390 462
391 463 if ($isUsingCart) {
392 464 $cart = CartHelper::getCart();
@@ -422,10 +494,16 @@
422 494 */
423 495 public static function calculateShippingByProfile($shippingMethodId, $cartItems, $country, $state = null, $returnType = 'amount')
424 496 {
425 497 $cartCheckoutService = new CheckoutService($cartItems);
498 + $isAllDigital = $cartCheckoutService->isAllDigital();
499 + $physicalItems = $cartCheckoutService->physicalItems;
426 500
427 - if ($cartCheckoutService->isAllDigital()) {
501 + // Exclude only physical items marked for free shipping from profile-based charges.
502 + static::excludeFreeShippingPhysicalItems($cartItems, $physicalItems);
503 +
504 + // No shipping is charged for all-digital carts or when every physical item has free shipping.
505 + if ($isAllDigital || empty($physicalItems)) {
428 506 if ($returnType === 'items') {
429 507 foreach ($cartItems as $key => $item) {
430 508 $cartItems[$key]['shipping_charge'] = 0;
431 509 $cartItems[$key]['itemwise_shipping_charge'] = 0;
@@ -434,10 +512,8 @@
434 512 }
435 513 return 0;
436 514 }
437 515
438 - $physicalItems = $cartCheckoutService->physicalItems;
439 -
440 516 // Load products with details
441 517 $productIds = array_unique(array_column($physicalItems, 'post_id'));
442 518 $products = Product::query()->whereIn('ID', $productIds)
443 519 ->with(['detail'])
@@ -477,42 +553,22 @@
477 553 }
478 554 return 0;
479 555 }
480 556
481 - $totalShippingAmount = 0;
482 -
483 - // Preload all class-specific methods in a single query
484 - $classMethodsMap = [];
485 - $classIdsWithMethods = array_filter(array_unique(array_column($groups, 'class_id')));
486 - if ($classIdsWithMethods && $country) {
487 - $allClassMethods = ShippingMethod::query()
488 - ->whereHas('zone', function ($q) use ($country, $classIdsWithMethods) {
489 - $q->where(function ($zq) use ($country) {
490 - $zq->whereIn('region', [$country, 'all'])
491 - ->orWhere('region', 'selection');
492 - })
493 - ->whereIn('shipping_class_id', $classIdsWithMethods);
494 - })
495 - ->where('is_enabled', 1)
496 - ->orderBy('amount', 'DESC')
497 - ->with('zone')
498 - ->get()
499 - ->filter(function ($method) use ($country) {
500 - if (!$method->zone || $method->zone->region !== 'selection') {
501 - return true;
502 - }
503 - return $method->zone->appliesToCountry($country);
504 - });
505 -
506 - // Group by shipping_class_id
507 - foreach ($allClassMethods as $method) {
508 - $classId = $method->zone->shipping_class_id ?? null;
509 - if ($classId) {
510 - $classMethodsMap[$classId][] = $method;
557 + // Early return for free_shipping — no base rate, no class surcharges
558 + if ($selectedMethod->type === 'free_shipping') {
559 + if ($returnType === 'items') {
560 + foreach ($cartItems as $key => $item) {
561 + $cartItems[$key]['shipping_charge'] = 0;
562 + $cartItems[$key]['itemwise_shipping_charge'] = 0;
511 563 }
564 + return ['items' => $cartItems, 'shipping_amount' => 0];
512 565 }
566 + return 0;
513 567 }
514 568
569 + $totalShippingAmount = 0;
570 +
515 571 // Preload all variations for weight calculation (avoids N+1 per group)
516 572 $allVariationIds = [];
517 573 foreach ($groups as $group) {
518 574 foreach ($group['items'] as $item) {
@@ -524,39 +580,84 @@
524 580 $allVariationsMap = !empty($allVariationIds)
525 581 ? ProductVariation::query()->whereIn('id', $allVariationIds)->get()->keyBy('id')
526 582 : new \FluentCart\Framework\Support\Collection();
527 583
528 - foreach ($groups as $groupKey => &$group) {
529 - $classId = $group['class_id'];
530 - $groupItems = $group['items'];
584 + // Compute cart-wide totals BEFORE the group loop (for per_order/per_price/per_weight base rate)
585 + $cartTotalPrice = 0;
586 + $cartTotalQuantity = 0;
587 + foreach ($physicalItems as $item) {
588 + $quantity = Arr::get($item, 'quantity', 1);
589 + $cartTotalQuantity += $quantity;
590 + $cartTotalPrice += ($quantity * Arr::get($item, 'unit_price', 0)) - Arr::get($item, 'discount_total', 0);
591 + }
531 592
532 - // Find the applicable method for this group (from preloaded map)
533 - $groupMethod = $selectedMethod;
534 - if ($classId && isset($classMethodsMap[$classId])) {
535 - $classMethods = $classMethodsMap[$classId];
536 - $matched = false;
537 - foreach ($classMethods as $m) {
538 - if ($m->id == $shippingMethodId) {
539 - $groupMethod = $m;
540 - $matched = true;
541 - break;
593 + // Calculate the method base rate ONCE using cart-wide totals
594 + $settings = Arr::wrap($selectedMethod->settings);
595 + $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
596 + $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
597 +
598 + if ($configureRate === 'per_order') {
599 + $methodBaseRate = Helper::toCent($selectedMethod->amount);
600 + } elseif ($configureRate === 'per_price') {
601 + $methodBaseRate = $cartTotalPrice * ($selectedMethod->amount / 100);
602 + } elseif ($configureRate === 'per_weight') {
603 + $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
604 + $totalWeight = 0;
605 +
606 + foreach ($physicalItems as $item) {
607 + $varId = Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
608 + $variation = $varId ? $allVariationsMap->get($varId) : null;
609 + if ($variation) {
610 + $otherInfo = $variation->other_info ?: [];
611 + $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
612 + $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
613 + $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
614 +
615 + $packageSlug = Arr::get($otherInfo, 'package_slug', '');
616 + $package = Helper::getPackageBySlug($packageSlug);
617 + $packageWeight = 0;
618 + if ($package) {
619 + $packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit);
620 + $packageWeight = Helper::convertWeight(
621 + floatval(Arr::get($package, 'weight', 0)),
622 + $packageWeightUnit,
623 + $storeWeightUnit
624 + );
542 625 }
626 +
627 + $totalWeight += ($convertedProductWeight + $packageWeight) * Arr::get($item, 'quantity', 1);
543 628 }
544 - if (!$matched) {
545 - $groupMethod = $classMethods[0];
629 + }
630 +
631 + $weightTiers = Arr::get($settings, 'weight_tiers', []);
632 + $methodBaseRate = 0;
633 + foreach ($weightTiers as $tier) {
634 + $min = floatval(Arr::get($tier, 'min', 0));
635 + $max = floatval(Arr::get($tier, 'max', 0));
636 + if ($totalWeight >= $min && ($max <= 0 || $totalWeight <= $max)) {
637 + $methodBaseRate = Helper::toCent(floatval(Arr::get($tier, 'cost', 0)));
638 + break;
546 639 }
547 640 }
641 + } else {
642 + // per_item
643 + $methodBaseRate = Helper::toCent($selectedMethod->amount) * $cartTotalQuantity;
644 + }
548 645
549 - // Calculate group totals
550 - $groupTotalPrice = 0;
551 - $groupTotalQuantity = 0;
646 + // Accumulate class surcharges across all groups
647 + $allGroupsClassCharge = 0;
648 + $allGroupsMaxClassCharge = 0;
649 +
650 + foreach ($groups as $groupKey => &$group) {
651 + $classId = $group['class_id'];
652 + $groupItems = $group['items'];
653 +
654 + // Calculate class surcharges for this group
655 + $groupTotalClassCharge = 0;
552 656 $groupMaxClassCharge = 0;
553 - $groupTotalClassCharge = 0;
554 657
555 658 foreach ($groupItems as &$gItem) {
556 659 $quantity = Arr::get($gItem, 'quantity', 1);
557 - $groupTotalQuantity += $quantity;
558 - $groupTotalPrice += ($quantity * Arr::get($gItem, 'unit_price', 0)) - Arr::get($gItem, 'discount_total', 0);
559 660
560 661 // Calculate class surcharge per item
561 662 $itemClassCharge = 0;
562 663 if ($classId && $shippingClasses->has($classId)) {
@@ -574,103 +675,60 @@
574 675 $groupMaxClassCharge = max($groupMaxClassCharge, $itemClassCharge);
575 676 }
576 677 unset($gItem);
577 678
578 - // Calculate method-level amount for this group
579 - $settings = Arr::wrap($groupMethod->settings);
580 - $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
581 - $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
679 + $allGroupsClassCharge += $groupTotalClassCharge;
680 + $allGroupsMaxClassCharge = max($allGroupsMaxClassCharge, $groupMaxClassCharge);
582 681
583 - if ($groupMethod->type === 'free_shipping') {
584 - $methodAmount = 0;
585 - } elseif ($configureRate === 'per_order') {
586 - $methodAmount = $groupMethod->amount * 100;
587 - } elseif ($configureRate === 'per_price') {
588 - $methodAmount = $groupTotalPrice * ($groupMethod->amount / 100);
589 - } elseif ($configureRate === 'per_weight') {
590 - $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
591 - $totalWeight = 0;
682 + $group['items'] = $groupItems;
683 + $group['class_charge'] = $groupTotalClassCharge;
684 + }
685 + unset($group);
592 686
593 - foreach ($groupItems as $gItem) {
594 - $varId = Arr::get($gItem, 'object_id', Arr::get($gItem, 'variation_id'));
595 - $variation = $varId ? $allVariationsMap->get($varId) : null;
596 - if ($variation) {
597 - $otherInfo = $variation->other_info ?: [];
598 - $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
599 - $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
600 - $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
687 + // Compute total: base rate (once) + class surcharges
688 + if ($classAggregation === 'highest_class') {
689 + $totalShippingAmount = $methodBaseRate + $allGroupsMaxClassCharge;
690 + } else {
691 + $totalShippingAmount = $methodBaseRate + $allGroupsClassCharge;
692 + }
601 693
602 - $packageSlug = Arr::get($otherInfo, 'package_slug', '');
603 - $package = Helper::getPackageBySlug($packageSlug);
604 - $packageWeight = 0;
605 - if ($package) {
606 - $packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit);
607 - $packageWeight = Helper::convertWeight(
608 - floatval(Arr::get($package, 'weight', 0)),
609 - $packageWeightUnit,
610 - $storeWeightUnit
611 - );
612 - }
694 + // Distribute the total shipping amount across all physical items proportionally
695 + $totalLineTotal = 0;
696 + foreach ($physicalItems as $item) {
697 + $totalLineTotal += Arr::get($item, 'line_total', 0);
698 + }
613 699
614 - $totalWeight += ($convertedProductWeight + $packageWeight) * Arr::get($gItem, 'quantity', 1);
615 - }
616 - }
617 - $weightTiers = Arr::get($settings, 'weight_tiers', []);
618 - $methodAmount = 0;
619 - foreach ($weightTiers as $tier) {
620 - $min = floatval(Arr::get($tier, 'min', 0));
621 - $max = floatval(Arr::get($tier, 'max', 0));
622 - if ($totalWeight >= $min && ($max <= 0 || $totalWeight <= $max)) {
623 - $methodAmount = Helper::toCent(floatval(Arr::get($tier, 'cost', 0)));
624 - break;
625 - }
626 - }
627 - } else {
628 - // per_item
629 - $methodAmount = $groupMethod->amount * $groupTotalQuantity * 100;
630 - }
700 + $methodOnlyAmount = $methodBaseRate;
701 + $distributed = 0;
702 + $itemCount = count($physicalItems);
631 703
632 - // Add class aggregation
633 - if ($classAggregation === 'highest_class') {
634 - $methodAmount += $groupMaxClassCharge;
635 - } else {
636 - $methodAmount += $groupTotalClassCharge;
637 - }
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;
638 711
639 - // Distribute method-level portion across group items proportionally
640 - $methodOnlyAmount = $methodAmount - $groupTotalClassCharge;
641 - if ($methodOnlyAmount < 0) {
642 - $methodOnlyAmount = 0;
643 - }
644 -
645 - $totalLineTotal = 0;
646 - foreach ($groupItems as $gItem) {
647 - $totalLineTotal += Arr::get($gItem, 'line_total', 0);
648 - }
649 -
650 - $distributed = 0;
651 - $itemCount = count($groupItems);
712 + foreach ($groups as $groupKey => &$group) {
713 + $groupItems = $group['items'];
652 714 foreach ($groupItems as $idx => &$gItem) {
653 - if ($totalLineTotal > 0) {
654 - $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);
655 719 } else {
656 - $share = $itemCount > 0 ? ($methodOnlyAmount / $itemCount) : 0;
720 + $share = $itemCount > 0 ? (int) round($methodOnlyAmount / $itemCount) : 0;
657 721 }
658 - $share = round($share, 2);
659 - $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;
660 726 $distributed += $share;
661 727 }
662 728 unset($gItem);
663 -
664 - $diff = round($methodOnlyAmount - $distributed, 2);
665 - if ($diff != 0 && !empty($groupItems)) {
666 - $lastIdx = array_key_last($groupItems);
667 - $groupItems[$lastIdx]['itemwise_shipping_charge'] += ceil($diff);
668 - }
669 -
670 729 $group['items'] = $groupItems;
671 - $group['amount'] = $methodAmount;
672 - $totalShippingAmount += $methodAmount;
730 + $group['amount'] = $group['class_charge'];
673 731 }
674 732 unset($group);
675 733
676 734 // Merge group items back into cartItems
@@ -732,9 +790,10 @@
732 790 {
733 791 if (is_user_logged_in()) {
734 792 $wpUser = wp_get_current_user();
735 793 $cart->user_id = get_current_user_id();
736 - $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();
737 796 if ($customer) {
738 797 $cart->customer_id = $customer->id;
739 798 }
740 799 $cart->email = $wpUser->user_email;
@@ -827,6 +886,45 @@
827 886 if (empty($variationId)) {
828 887 return false;
829 888 }
830 889 return $variationId;
890 + }
891 +
892 + /**
893 + * @param \FluentCart\App\Models\Cart $cart
894 + * @param array|\WP_Error $methods
895 + * @param string|int|null $currentSelectedId
896 + * @return string|int|null
897 + */
898 + public static function resolveAutoSelectShippingMethod($cart, $methods, $currentSelectedId)
899 + {
900 + if ($currentSelectedId || empty($methods) || is_wp_error($methods) || count($methods) !== 1) {
901 + return $currentSelectedId;
902 + }
903 +
904 + $method = $methods[0];
905 +
906 + $shouldAutoSelect = apply_filters('fluent_cart/shipping/auto_select_single_method', true, [
907 + 'cart' => $cart,
908 + 'method' => $method,
909 + ]);
910 +
911 + if (!$shouldAutoSelect) {
912 + return $currentSelectedId;
913 + }
914 +
915 + $charge = static::calculateShippingMethodCharge($method, $cart->cart_data);
916 +
917 + $cart->checkout_data = array_merge(
918 + (array) $cart->checkout_data,
919 + [
920 + 'shipping_data' => [
921 + 'shipping_method_id' => $method->id,
922 + 'shipping_charge' => is_array($charge) ? Arr::get($charge, 'shipping_amount', 0) : $charge,
923 + ],
924 + ]
925 + );
926 + $cart->save();
927 +
928 + return $method->id;
831 929 }
832 930 }