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 +215 -96 1.3.26 → 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'])
@@ -623,18 +699,31 @@
623 699
624 700 $methodOnlyAmount = $methodBaseRate;
625 701 $distributed = 0;
626 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 +
627 712 foreach ($groups as $groupKey => &$group) {
628 713 $groupItems = $group['items'];
629 714 foreach ($groupItems as $idx => &$gItem) {
630 - if ($totalLineTotal > 0) {
631 - $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);
632 719 } else {
633 - $share = $itemCount > 0 ? ($methodOnlyAmount / $itemCount) : 0;
720 + $share = $itemCount > 0 ? (int) round($methodOnlyAmount / $itemCount) : 0;
634 721 }
635 - $share = round($share, 2);
636 - $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;
637 726 $distributed += $share;
638 727 }
639 728 unset($gItem);
640 729 $group['items'] = $groupItems;
@@ -641,18 +730,8 @@
641 730 $group['amount'] = $group['class_charge'];
642 731 }
643 732 unset($group);
644 733
645 - // Correct rounding difference on last physical item
646 - $diff = round($methodOnlyAmount - $distributed, 2);
647 - if ($diff != 0) {
648 - $lastGroupKey = array_key_last($groups);
649 - if ($lastGroupKey !== null && !empty($groups[$lastGroupKey]['items'])) {
650 - $lastItemIdx = array_key_last($groups[$lastGroupKey]['items']);
651 - $groups[$lastGroupKey]['items'][$lastItemIdx]['itemwise_shipping_charge'] += ceil($diff);
652 - }
653 - }
654 -
655 734 // Merge group items back into cartItems
656 735 foreach ($groups as $group) {
657 736 foreach ($group['keys'] as $i => $key) {
658 737 if (isset($group['items'][$i])) {
@@ -711,9 +790,10 @@
711 790 {
712 791 if (is_user_logged_in()) {
713 792 $wpUser = wp_get_current_user();
714 793 $cart->user_id = get_current_user_id();
715 - $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();
716 796 if ($customer) {
717 797 $cart->customer_id = $customer->id;
718 798 }
719 799 $cart->email = $wpUser->user_email;
@@ -806,6 +886,45 @@
806 886 if (empty($variationId)) {
807 887 return false;
808 888 }
809 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;
810 929 }
811 930 }