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 +144 -104 1.5.0 → 1.6.5 View file →
@@ -95,8 +95,21 @@
95 95 ]);
96 96
97 97 // $cartItem['shipping_charge'] = $shippingCharge;
98 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 +
99 112 return $cartItem;
100 113 }
101 114
102 115 public static function generateCartItemCustomItem(array $variation, $quantity = 1): array
@@ -190,9 +203,9 @@
190 203
191 204 private static function excludeFreeShippingPhysicalItems(array &$items, array &$physicalItems): void
192 205 {
193 206 foreach ($physicalItems as $key => $item) {
194 - if (static::itemHasFreeShipping($item)) {
207 + if (self::itemHasFreeShipping($item)) {
195 208 $items[$key]['shipping_charge'] = 0;
196 209 $items[$key]['itemwise_shipping_charge'] = 0;
197 210 unset($physicalItems[$key]);
198 211 }
@@ -201,36 +214,62 @@
201 214
202 215 public static function calculateShippingMethodCharge(ShippingMethod $method, ?array $items = null, $returnType = 'amount')
203 216 {
204 217 static $onceCalculated = false;
205 - static $onceDistributed = false;
206 - static $totalItemPrice = 0;
207 - static $totalQuantity = 0;
208 - static $physicalItems = [];
209 - static $isAllDigital = false;
210 - static $maxShippingCharge = 0;
211 - static $totalShippingCharge = 0;
212 - 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;
213 233 $isUsingCart = false;
214 234
215 - // Reset statics when called with a different method to prevent stale state
216 - if ($lastMethodId !== $method->id) {
217 - $onceCalculated = false;
218 - $onceDistributed = false;
219 - $totalItemPrice = 0;
220 - $totalQuantity = 0;
221 - $physicalItems = [];
222 - $isAllDigital = false;
223 - $maxShippingCharge = 0;
224 - $totalShippingCharge = 0;
225 - $lastMethodId = $method->id;
226 - }
227 -
228 235 if ($items === null) {
229 236 $isUsingCart = true;
230 237 $items = static::getCart()->cart_data ?? [];
231 238 }
232 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 +
233 272 if ($method->type === 'free_shipping') {
234 273 if ($returnType === 'items') {
235 274 if ($items === null) {
236 275 $items = static::getCart()->cart_data ?? [];
@@ -246,10 +285,8 @@
246 285 }
247 286 return 0;
248 287 }
249 288
250 - $totalItemWiseShippingCharge = 0;
251 -
252 289 $cartCheckoutService = new CheckoutService($items);
253 290 $isAllDigital = $cartCheckoutService->isAllDigital();
254 291 $physicalItems = $cartCheckoutService->physicalItems;
255 292
@@ -255,8 +292,23 @@
255 292
256 293 // Exclude only physical items marked for free shipping from charge calculation.
257 294 static::excludeFreeShippingPhysicalItems($items, $physicalItems);
258 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 +
259 311 if (!$onceCalculated) {
260 312 $onceCalculated = true;
261 313 $productIds = array_unique(array_column($physicalItems, 'post_id'));
262 314 $products = Product::query()->whereIn('ID', $productIds)
@@ -268,58 +320,47 @@
268 320 return !empty($item);
269 321 })->toArray();
270 322
271 323 $shippingClasses = ShippingClass::query()->whereIn('id', $shippingClassIds)->get()->keyBy('id');
324 + }
272 325
273 - foreach ($physicalItems as $key => &$item) {
274 - $totalQuantity += Arr::get($item, 'quantity');
275 - $totalItemPrice += (Arr::get($item, 'quantity') * Arr::get($item, 'unit_price')) - Arr::get($item, 'discount_total');
276 - $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;
277 335
278 - $product = $products->get(Arr::get($item, 'post_id'));
336 + $product = $products->get(Arr::get($item, 'post_id'));
279 337
280 338
281 - if (isset($product->detail->other_info['shipping_class'])) {
282 - // shipping_class is null or not defined
283 - $shippingClass = $shippingClasses->get(
284 - $product->detail->other_info['shipping_class']
285 - );
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 + );
286 344
287 - if ($shippingClass) {
288 - $perItem = $shippingClass->per_item;
289 - $factor = empty($perItem) ? 1 : Arr::get($item, 'quantity');
290 - if ($shippingClass->type === 'percentage') {
291 - $itemShippingCharge = ($shippingClass->cost / 100) * Arr::get($item, 'unit_price') * $factor;
292 - } else {
293 - $itemShippingCharge = Helper::toCent($shippingClass->cost) * $factor;
294 - }
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;
295 352 }
296 353 }
297 - $item['shipping_charge'] = $itemShippingCharge;
298 - $totalShippingCharge += $itemShippingCharge;
299 -
300 - $items[$key] = $item;
301 - $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
302 354 }
355 + $item['shipping_charge'] = $itemShippingCharge;
356 + $totalShippingCharge += $itemShippingCharge;
303 357
304 - $totalItemWiseShippingCharge = $totalShippingCharge;
358 + $items[$key] = $item;
359 + $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
305 360 }
361 + unset($item);
306 362
307 - // No shipping is charged for all-digital carts or when every physical item has free shipping.
308 - if ($isAllDigital || empty($physicalItems)) {
309 - if ($returnType === 'items') {
310 - foreach ($items as $key => $item) {
311 - $items[$key]['shipping_charge'] = 0;
312 - $items[$key]['itemwise_shipping_charge'] = 0;
313 - }
314 - return [
315 - 'items' => $items,
316 - 'shipping_amount' => 0
317 - ];
318 - }
319 - return 0;
320 - }
321 -
322 363 $settings = Arr::wrap($method->settings);
323 364 $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
324 365 $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
325 366
@@ -392,37 +433,32 @@
392 433 }
393 434
394 435 $shippingMethodAmount = (int)round($shippingMethodAmount);
395 436
396 - $remainingShippingMethodAmount = ($shippingMethodAmount - $totalItemWiseShippingCharge);
437 + $remainingShippingMethodAmount = ($shippingMethodAmount - $totalShippingCharge);
397 438
398 - if (!$onceDistributed) {
399 - $onceDistributed = true;
400 - $totalLineTotal = array_sum(array_column($physicalItems, 'line_total'));
401 - $distributed = 0;
402 - $totalRemain = $remainingShippingMethodAmount;
403 - $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);
404 448
405 - if ($totalLineTotal > 0) {
406 - foreach ($physicalItems as $key => &$item) {
407 - $share = ($item['line_total'] / $totalLineTotal) * $remainingShippingMethodAmount;
408 - $share = round($share, 2);
409 - $items[$key]['itemwise_shipping_charge'] = ceil($share);
410 - $distributed += $share;
411 - }
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);
412 456 } else {
413 - $equalShare = round($remainingShippingMethodAmount / $itemCount, 2);
414 - foreach ($physicalItems as $key => &$item) {
415 - $items[$key]['itemwise_shipping_charge'] = ceil($equalShare);
416 - $distributed += $equalShare;
417 - }
457 + $share = (int) round($remainingShippingMethodAmount / $itemCount);
418 458 }
419 -
420 - $diff = round($totalRemain - $distributed, 2);
421 - if ($diff != 0) {
422 - $lastIndex = array_key_last($physicalItems);
423 - $items[$lastIndex]['itemwise_shipping_charge'] = ceil($diff);
424 - }
459 + $items[$key]['itemwise_shipping_charge'] = $share;
460 + $distributed += $share;
425 461 }
426 462
427 463 if ($isUsingCart) {
428 464 $cart = CartHelper::getCart();
@@ -663,18 +699,31 @@
663 699
664 700 $methodOnlyAmount = $methodBaseRate;
665 701 $distributed = 0;
666 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 +
667 712 foreach ($groups as $groupKey => &$group) {
668 713 $groupItems = $group['items'];
669 714 foreach ($groupItems as $idx => &$gItem) {
670 - if ($totalLineTotal > 0) {
671 - $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);
672 719 } else {
673 - $share = $itemCount > 0 ? ($methodOnlyAmount / $itemCount) : 0;
720 + $share = $itemCount > 0 ? (int) round($methodOnlyAmount / $itemCount) : 0;
674 721 }
675 - $share = round($share, 2);
676 - $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;
677 726 $distributed += $share;
678 727 }
679 728 unset($gItem);
680 729 $group['items'] = $groupItems;
@@ -681,18 +730,8 @@
681 730 $group['amount'] = $group['class_charge'];
682 731 }
683 732 unset($group);
684 733
685 - // Correct rounding difference on last physical item
686 - $diff = round($methodOnlyAmount - $distributed, 2);
687 - if ($diff != 0) {
688 - $lastGroupKey = array_key_last($groups);
689 - if ($lastGroupKey !== null && !empty($groups[$lastGroupKey]['items'])) {
690 - $lastItemIdx = array_key_last($groups[$lastGroupKey]['items']);
691 - $groups[$lastGroupKey]['items'][$lastItemIdx]['itemwise_shipping_charge'] += ceil($diff);
692 - }
693 - }
694 -
695 734 // Merge group items back into cartItems
696 735 foreach ($groups as $group) {
697 736 foreach ($group['keys'] as $i => $key) {
698 737 if (isset($group['items'][$i])) {
@@ -751,9 +790,10 @@
751 790 {
752 791 if (is_user_logged_in()) {
753 792 $wpUser = wp_get_current_user();
754 793 $cart->user_id = get_current_user_id();
755 - $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();
756 796 if ($customer) {
757 797 $cart->customer_id = $customer->id;
758 798 }
759 799 $cart->email = $wpUser->user_email;