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 +149 -105 1.4.2 → 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
@@ -388,37 +433,32 @@
388 433 }
389 434
390 435 $shippingMethodAmount = (int)round($shippingMethodAmount);
391 436
392 - $remainingShippingMethodAmount = ($shippingMethodAmount - $totalItemWiseShippingCharge);
437 + $remainingShippingMethodAmount = ($shippingMethodAmount - $totalShippingCharge);
393 438
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);
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);
400 448
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 - }
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);
408 456 } 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 - }
457 + $share = (int) round($remainingShippingMethodAmount / $itemCount);
414 458 }
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 - }
459 + $items[$key]['itemwise_shipping_charge'] = $share;
460 + $distributed += $share;
421 461 }
422 462
423 463 if ($isUsingCart) {
424 464 $cart = CartHelper::getCart();
@@ -659,18 +699,31 @@
659 699
660 700 $methodOnlyAmount = $methodBaseRate;
661 701 $distributed = 0;
662 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 +
663 712 foreach ($groups as $groupKey => &$group) {
664 713 $groupItems = $group['items'];
665 714 foreach ($groupItems as $idx => &$gItem) {
666 - if ($totalLineTotal > 0) {
667 - $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);
668 719 } else {
669 - $share = $itemCount > 0 ? ($methodOnlyAmount / $itemCount) : 0;
720 + $share = $itemCount > 0 ? (int) round($methodOnlyAmount / $itemCount) : 0;
670 721 }
671 - $share = round($share, 2);
672 - $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;
673 726 $distributed += $share;
674 727 }
675 728 unset($gItem);
676 729 $group['items'] = $groupItems;
@@ -677,18 +730,8 @@
677 730 $group['amount'] = $group['class_charge'];
678 731 }
679 732 unset($group);
680 733
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 734 // Merge group items back into cartItems
692 735 foreach ($groups as $group) {
693 736 foreach ($group['keys'] as $i => $key) {
694 737 if (isset($group['items'][$i])) {
@@ -747,9 +790,10 @@
747 790 {
748 791 if (is_user_logged_in()) {
749 792 $wpUser = wp_get_current_user();
750 793 $cart->user_id = get_current_user_id();
751 - $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();
752 796 if ($customer) {
753 797 $cart->customer_id = $customer->id;
754 798 }
755 799 $cart->email = $wpUser->user_email;