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 +190 -105 1.3.27 → 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
@@ -186,9 +203,9 @@
186 203
187 204 private static function excludeFreeShippingPhysicalItems(array &$items, array &$physicalItems): void
188 205 {
189 206 foreach ($physicalItems as $key => $item) {
190 - if (static::itemHasFreeShipping($item)) {
207 + if (self::itemHasFreeShipping($item)) {
191 208 $items[$key]['shipping_charge'] = 0;
192 209 $items[$key]['itemwise_shipping_charge'] = 0;
193 210 unset($physicalItems[$key]);
194 211 }
@@ -197,36 +214,62 @@
197 214
198 215 public static function calculateShippingMethodCharge(ShippingMethod $method, ?array $items = null, $returnType = 'amount')
199 216 {
200 217 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;
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;
209 233 $isUsingCart = false;
210 234
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 235 if ($items === null) {
225 236 $isUsingCart = true;
226 237 $items = static::getCart()->cart_data ?? [];
227 238 }
228 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 +
229 272 if ($method->type === 'free_shipping') {
230 273 if ($returnType === 'items') {
231 274 if ($items === null) {
232 275 $items = static::getCart()->cart_data ?? [];
@@ -242,10 +285,8 @@
242 285 }
243 286 return 0;
244 287 }
245 288
246 - $totalItemWiseShippingCharge = 0;
247 -
248 289 $cartCheckoutService = new CheckoutService($items);
249 290 $isAllDigital = $cartCheckoutService->isAllDigital();
250 291 $physicalItems = $cartCheckoutService->physicalItems;
251 292
@@ -251,8 +292,23 @@
251 292
252 293 // Exclude only physical items marked for free shipping from charge calculation.
253 294 static::excludeFreeShippingPhysicalItems($items, $physicalItems);
254 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 +
255 311 if (!$onceCalculated) {
256 312 $onceCalculated = true;
257 313 $productIds = array_unique(array_column($physicalItems, 'post_id'));
258 314 $products = Product::query()->whereIn('ID', $productIds)
@@ -264,58 +320,47 @@
264 320 return !empty($item);
265 321 })->toArray();
266 322
267 323 $shippingClasses = ShippingClass::query()->whereIn('id', $shippingClassIds)->get()->keyBy('id');
324 + }
268 325
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;
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;
273 335
274 - $product = $products->get(Arr::get($item, 'post_id'));
336 + $product = $products->get(Arr::get($item, 'post_id'));
275 337
276 338
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 - );
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 + );
282 344
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 - }
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;
291 352 }
292 353 }
293 - $item['shipping_charge'] = $itemShippingCharge;
294 - $totalShippingCharge += $itemShippingCharge;
295 -
296 - $items[$key] = $item;
297 - $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
298 354 }
355 + $item['shipping_charge'] = $itemShippingCharge;
356 + $totalShippingCharge += $itemShippingCharge;
299 357
300 - $totalItemWiseShippingCharge = $totalShippingCharge;
358 + $items[$key] = $item;
359 + $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
301 360 }
361 + unset($item);
302 362
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 363 $settings = Arr::wrap($method->settings);
319 364 $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
320 365 $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
321 366
@@ -386,37 +431,34 @@
386 431 } else {
387 432 $shippingMethodAmount += $totalShippingCharge;
388 433 }
389 434
390 - $remainingShippingMethodAmount = ($shippingMethodAmount - $totalItemWiseShippingCharge);
435 + $shippingMethodAmount = (int)round($shippingMethodAmount);
391 436
392 - if (!$onceDistributed) {
393 - $onceDistributed = true;
394 - $totalLineTotal = array_sum(array_column($physicalItems, 'line_total'));
395 - $distributed = 0;
396 - $totalRemain = $remainingShippingMethodAmount;
397 - $itemCount = count($physicalItems);
437 + $remainingShippingMethodAmount = ($shippingMethodAmount - $totalShippingCharge);
398 438
399 - if ($totalLineTotal > 0) {
400 - foreach ($physicalItems as $key => &$item) {
401 - $share = ($item['line_total'] / $totalLineTotal) * $remainingShippingMethodAmount;
402 - $share = round($share, 2);
403 - $items[$key]['itemwise_shipping_charge'] = ceil($share);
404 - $distributed += $share;
405 - }
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);
406 456 } else {
407 - $equalShare = round($remainingShippingMethodAmount / $itemCount, 2);
408 - foreach ($physicalItems as $key => &$item) {
409 - $items[$key]['itemwise_shipping_charge'] = ceil($equalShare);
410 - $distributed += $equalShare;
411 - }
457 + $share = (int) round($remainingShippingMethodAmount / $itemCount);
412 458 }
413 -
414 - $diff = round($totalRemain - $distributed, 2);
415 - if ($diff != 0) {
416 - $lastIndex = array_key_last($physicalItems);
417 - $items[$lastIndex]['itemwise_shipping_charge'] = ceil($diff);
418 - }
459 + $items[$key]['itemwise_shipping_charge'] = $share;
460 + $distributed += $share;
419 461 }
420 462
421 463 if ($isUsingCart) {
422 464 $cart = CartHelper::getCart();
@@ -657,18 +699,31 @@
657 699
658 700 $methodOnlyAmount = $methodBaseRate;
659 701 $distributed = 0;
660 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 +
661 712 foreach ($groups as $groupKey => &$group) {
662 713 $groupItems = $group['items'];
663 714 foreach ($groupItems as $idx => &$gItem) {
664 - if ($totalLineTotal > 0) {
665 - $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);
666 719 } else {
667 - $share = $itemCount > 0 ? ($methodOnlyAmount / $itemCount) : 0;
720 + $share = $itemCount > 0 ? (int) round($methodOnlyAmount / $itemCount) : 0;
668 721 }
669 - $share = round($share, 2);
670 - $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;
671 726 $distributed += $share;
672 727 }
673 728 unset($gItem);
674 729 $group['items'] = $groupItems;
@@ -675,18 +730,8 @@
675 730 $group['amount'] = $group['class_charge'];
676 731 }
677 732 unset($group);
678 733
679 - // Correct rounding difference on last physical item
680 - $diff = round($methodOnlyAmount - $distributed, 2);
681 - if ($diff != 0) {
682 - $lastGroupKey = array_key_last($groups);
683 - if ($lastGroupKey !== null && !empty($groups[$lastGroupKey]['items'])) {
684 - $lastItemIdx = array_key_last($groups[$lastGroupKey]['items']);
685 - $groups[$lastGroupKey]['items'][$lastItemIdx]['itemwise_shipping_charge'] += ceil($diff);
686 - }
687 - }
688 -
689 734 // Merge group items back into cartItems
690 735 foreach ($groups as $group) {
691 736 foreach ($group['keys'] as $i => $key) {
692 737 if (isset($group['items'][$i])) {
@@ -745,9 +790,10 @@
745 790 {
746 791 if (is_user_logged_in()) {
747 792 $wpUser = wp_get_current_user();
748 793 $cart->user_id = get_current_user_id();
749 - $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();
750 796 if ($customer) {
751 797 $cart->customer_id = $customer->id;
752 798 }
753 799 $cart->email = $wpUser->user_email;
@@ -840,6 +886,45 @@
840 886 if (empty($variationId)) {
841 887 return false;
842 888 }
843 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;
844 929 }
845 930 }