PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Helpers / CartHelper.php

CartHelper.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Helpers/CartHelper.php

930 lines 37.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Helpers;
4
5 use FluentCart\Api\CurrencySettings;
6 use FluentCart\Api\Resource\FrontendResource\CartResource;
7 use FluentCart\App\App;
8 use FluentCart\App\Models\Cart;
9 use FluentCart\App\Models\Customer;
10 use FluentCart\App\Models\Product;
11 use FluentCart\App\Models\ProductVariation;
12 use FluentCart\App\Models\ShippingClass;
13 use FluentCart\App\Models\ShippingMethod;
14 use FluentCart\App\Services\CheckoutService;
15 use FluentCart\App\Services\URL;
16 use FluentCart\Framework\Support\Arr;
17
18 class CartHelper
19 {
20 public static function getCart($hash = null, $create = false)
21 {
22 return CartResource::get([
23 'hash' => $hash ?? App::request()->get(Helper::INSTANT_CHECKOUT_URL_PARAM),
24 'create' => $create
25 ]);
26 }
27
28 public static function generateCartItemFromVariation(ProductVariation $variation, $quantity = 1): array
29 {
30 $mediaUrl = $variation->thumbnail ?: $variation->product->thumbnail;
31
32 // $shippingCharge = static::calculateShippingCharge($variation, $quantity);
33
34 $itemPrice = apply_filters('fluent_cart/cart/item_price', $variation->item_price, [
35 'variation' => $variation,
36 'quantity' => $quantity,
37 ]);
38
39 // Ensure filtered price is a valid non-negative integer (cents)
40 $itemPrice = max(0, (int)$itemPrice);
41
42 $subtotal = $itemPrice * $quantity;
43
44 //Need to test and check this toArray Issue
45 $data = wp_parse_args([
46 'quantity' => $quantity,
47 'price' => $itemPrice,
48 'unit_price' => $itemPrice,
49 'line_total' => $itemPrice * $quantity,
50 'subtotal' => $subtotal,
51 'discount_total' => 0,
52 'tax_total' => 0,
53 'line_total_formatted' => CurrencySettings::getFormattedPrice($subtotal),
54 'object_id' => $variation->id,
55 'title' => $variation->variation_title,
56 'post_title' => $variation->product->post_title,
57 'coupon_discount' => 0,
58 'cost' => $variation->item_cost ?? 0,
59 'featured_media' => $mediaUrl,
60 'view_url' => URL::appendQueryParams(
61 $variation->product->view_url,
62 [
63 'selected' => $variation->id
64 ]
65 ),
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 : '',
71 'is_custom' => false,
72 ], $variation->toArray());
73
74 $cartItem = Arr::only($data, [
75 'id',
76 'object_id',
77 'post_id',
78 'quantity',
79 'post_title',
80 'title',
81 'price',
82 'unit_price',
83 'coupon_discount',
84 'fulfillment_type',
85 'featured_media',
86 'other_info',
87 'cost',
88 'view_url',
89 'line_total_formatted',
90 'line_total',
91 'subtotal',
92 'total',
93 'variation_type',
94 'is_custom'
95 ]);
96
97 // $cartItem['shipping_charge'] = $shippingCharge;
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
112 return $cartItem;
113 }
114
115 public static function generateCartItemCustomItem(array $variation, $quantity = 1): array
116 {
117 //Need to test and check this toArray Issue
118 $data = wp_parse_args(
119 [
120 'quantity' => $quantity,
121 'price' => Arr::get($variation, 'item_price'),
122 'unit_price' => Arr::get($variation, 'item_price'),
123 'object_id' => Arr::get($variation, 'id'),
124 'tax_amount' => Arr::get($variation, 'tax_amount', 0),
125 'title' => Arr::get($variation, 'variation_title'),
126 'post_title' => Arr::get($variation, 'post_title'),
127 'cost' => Arr::get($variation, 'item_cost', 0),
128 'featured_media' => Arr::get($variation, 'featured_media'),
129 'view_url' => Arr::get($variation, 'view_url'),
130 'variation_type' => Arr::get($variation, 'variation_type'),
131 'is_custom' => Arr::get($variation, 'is_custom', false),
132 ],
133 $variation
134 );
135
136 $manualDiscount = Arr::get($data, 'manual_discount', 0);
137 $couponDiscount = Arr::get($data, 'coupon_discount', 0);
138 $discountTotal = $manualDiscount + $couponDiscount;
139 $itemPrice = Arr::get($data, 'item_price', 0);
140 if (!is_numeric($itemPrice)) {
141 $itemPrice = 0;
142 }
143 $subtotal = $itemPrice * Arr::get($data, 'quantity');
144
145 $data['subtotal'] = $subtotal;
146 $data['manual_discount'] = $manualDiscount;
147 $data['coupon_discount'] = $couponDiscount;
148 $data['discount_total'] = $discountTotal;
149 $data['line_total'] = $subtotal - $discountTotal;
150
151 $cartItem = Arr::only($data, [
152 'id',
153 'object_id',
154 'post_id',
155 'quantity',
156 'post_title',
157 'title',
158 'price',
159 'unit_price',
160 'manual_discount',
161 'coupon_discount',
162 'discount_total',
163 'fulfillment_type',
164 'featured_media',
165 'other_info',
166 'cost',
167 'view_url',
168 'line_total',
169 'subtotal',
170 'total',
171 'variation_type',
172 'is_custom'
173 ]);
174
175 return $cartItem;
176 }
177
178 public static function calculateShippingCharge(ProductVariation $variation, int $quantity = 1)
179 {
180
181 if ($variation->fulfillment_type !== 'physical') {
182 return 0;
183 }
184
185 $shippingClass = $variation->shippingClass;
186
187 if (!$shippingClass) {
188 return 0;
189 }
190
191 $factor = empty($shippingClass->per_item) ? 1 : $quantity;
192 if ($shippingClass->type === 'percentage') {
193 return ($shippingClass->cost / 100) * $variation->item_price * $factor;
194 }
195
196 return ($shippingClass->cost * 100) * $factor;
197 }
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
215 public static function calculateShippingMethodCharge(ShippingMethod $method, ?array $items = null, $returnType = 'amount')
216 {
217 static $onceCalculated = false;
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;
233 $isUsingCart = false;
234
235 if ($items === null) {
236 $isUsingCart = true;
237 $items = static::getCart()->cart_data ?? [];
238 }
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
272 if ($method->type === 'free_shipping') {
273 if ($returnType === 'items') {
274 if ($items === null) {
275 $items = static::getCart()->cart_data ?? [];
276 }
277 foreach ($items as $key => $item) {
278 $items[$key]['shipping_charge'] = 0;
279 $items[$key]['itemwise_shipping_charge'] = 0;
280 }
281 return [
282 'items' => $items,
283 'shipping_amount' => 0
284 ];
285 }
286 return 0;
287 }
288
289 $cartCheckoutService = new CheckoutService($items);
290 $isAllDigital = $cartCheckoutService->isAllDigital();
291 $physicalItems = $cartCheckoutService->physicalItems;
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
311 if (!$onceCalculated) {
312 $onceCalculated = true;
313 $productIds = array_unique(array_column($physicalItems, 'post_id'));
314 $products = Product::query()->whereIn('ID', $productIds)
315 ->with(['detail'])
316 ->get()
317 ->keyBy('ID');
318
319 $shippingClassIds = $products->pluck('detail.other_info.shipping_class')->filter(function ($item) {
320 return !empty($item);
321 })->toArray();
322
323 $shippingClasses = ShippingClass::query()->whereIn('id', $shippingClassIds)->get()->keyBy('id');
324 }
325
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;
335
336 $product = $products->get(Arr::get($item, 'post_id'));
337
338
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 );
344
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;
352 }
353 }
354 }
355 $item['shipping_charge'] = $itemShippingCharge;
356 $totalShippingCharge += $itemShippingCharge;
357
358 $items[$key] = $item;
359 $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
360 }
361 unset($item);
362
363 $settings = Arr::wrap($method->settings);
364 $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
365 $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
366
367 if ($configureRate === 'per_order') {
368 $shippingMethodAmount = $method->amount * 100;
369 } else if ($configureRate === 'per_price') {
370 $shippingMethodAmount = $totalItemPrice * ($method->amount / 100);
371 } else if ($configureRate === 'per_weight') {
372 // Sum (product weight + package weight) * quantity for all physical items
373 $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
374 $totalWeight = 0;
375
376 // Batch-load all variations to avoid N+1
377 $variationIds = array_filter(array_map(function ($item) {
378 return Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
379 }, $physicalItems));
380 $variationsMap = $variationIds ? ProductVariation::query()->whereIn('id', $variationIds)->get()->keyBy('id') : new \FluentCart\Framework\Support\Collection();
381
382 foreach ($physicalItems as $item) {
383 $variationId = Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
384 if ($variationId) {
385 $variation = $variationsMap->get($variationId);
386 if ($variation) {
387 $otherInfo = $variation->other_info ?: [];
388 $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
389 $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
390
391 // Convert product weight to store unit
392 $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
393
394 // Add package weight
395 $packageSlug = Arr::get($otherInfo, 'package_slug', '');
396 $package = Helper::getPackageBySlug($packageSlug);
397 $packageWeight = 0;
398 if ($package) {
399 $packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit);
400 $packageWeight = Helper::convertWeight(
401 floatval(Arr::get($package, 'weight', 0)),
402 $packageWeightUnit,
403 $storeWeightUnit
404 );
405 }
406
407 $totalWeight += ($convertedProductWeight + $packageWeight) * Arr::get($item, 'quantity', 1);
408 }
409 }
410 }
411
412 // Look up matching tier from weight_tiers
413 $weightTiers = Arr::get($settings, 'weight_tiers', []);
414 $shippingMethodAmount = 0;
415 foreach ($weightTiers as $tier) {
416 $min = floatval(Arr::get($tier, 'min', 0));
417 $max = floatval(Arr::get($tier, 'max', 0));
418 $cost = floatval(Arr::get($tier, 'cost', 0));
419
420 if ($totalWeight >= $min && ($max <= 0 || $totalWeight <= $max)) {
421 $shippingMethodAmount = Helper::toCent($cost);
422 break;
423 }
424 }
425 } else {
426 $shippingMethodAmount = $method->amount * $totalQuantity * 100;
427 }
428
429 if ($classAggregation === 'highest_class') {
430 $shippingMethodAmount += $maxShippingCharge;
431 } else {
432 $shippingMethodAmount += $totalShippingCharge;
433 }
434
435 $shippingMethodAmount = (int)round($shippingMethodAmount);
436
437 $remainingShippingMethodAmount = ($shippingMethodAmount - $totalShippingCharge);
438
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);
456 } else {
457 $share = (int) round($remainingShippingMethodAmount / $itemCount);
458 }
459 $items[$key]['itemwise_shipping_charge'] = $share;
460 $distributed += $share;
461 }
462
463 if ($isUsingCart) {
464 $cart = CartHelper::getCart();
465 $cart->cart_data = $items;
466 $cart->save();
467
468 do_action('fluent_cart/checkout/shipping_data_changed', [
469 'cart' => $cart
470 ]);
471 }
472
473 if ($returnType === 'items') {
474 return [
475 'items' => $items,
476 'shipping_amount' => $shippingMethodAmount
477 ];
478 }
479
480 return $shippingMethodAmount;
481 }
482
483 /**
484 * Calculate shipping charges using the profile-based approach.
485 * Groups cart items by shipping class, finds applicable methods per profile,
486 * falls back to General zones when no class-specific zones exist.
487 *
488 * @param int $shippingMethodId The selected shipping method ID
489 * @param array $cartItems Cart items
490 * @param string $country Country code
491 * @param string|null $state State code
492 * @param string $returnType 'amount' or 'items'
493 * @return int|array
494 */
495 public static function calculateShippingByProfile($shippingMethodId, $cartItems, $country, $state = null, $returnType = 'amount')
496 {
497 $cartCheckoutService = new CheckoutService($cartItems);
498 $isAllDigital = $cartCheckoutService->isAllDigital();
499 $physicalItems = $cartCheckoutService->physicalItems;
500
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)) {
506 if ($returnType === 'items') {
507 foreach ($cartItems as $key => $item) {
508 $cartItems[$key]['shipping_charge'] = 0;
509 $cartItems[$key]['itemwise_shipping_charge'] = 0;
510 }
511 return ['items' => $cartItems, 'shipping_amount' => 0];
512 }
513 return 0;
514 }
515
516 // Load products with details
517 $productIds = array_unique(array_column($physicalItems, 'post_id'));
518 $products = Product::query()->whereIn('ID', $productIds)
519 ->with(['detail'])
520 ->get()
521 ->keyBy('ID');
522
523 // Group physical items by shipping_class_id (null = General group)
524 $groups = [];
525 foreach ($physicalItems as $key => $item) {
526 $product = $products->get(Arr::get($item, 'post_id'));
527 $classId = null;
528 if ($product && isset($product->detail->other_info['shipping_class'])) {
529 $classId = $product->detail->other_info['shipping_class'] ?: null;
530 }
531 $groupKey = $classId ?: 'general';
532 if (!isset($groups[$groupKey])) {
533 $groups[$groupKey] = [
534 'class_id' => $classId,
535 'items' => [],
536 'keys' => []
537 ];
538 }
539 $groups[$groupKey]['items'][] = $item;
540 $groups[$groupKey]['keys'][] = $key;
541 }
542
543 // Load shipping classes for surcharge calculation
544 $classIds = array_filter(array_column($groups, 'class_id'));
545 $shippingClasses = !empty($classIds)
546 ? ShippingClass::query()->whereIn('id', $classIds)->get()->keyBy('id')
547 : new \FluentCart\Framework\Support\Collection();
548
549 $selectedMethod = ShippingMethod::find($shippingMethodId);
550 if (!$selectedMethod) {
551 if ($returnType === 'items') {
552 return ['items' => $cartItems, 'shipping_amount' => 0];
553 }
554 return 0;
555 }
556
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;
563 }
564 return ['items' => $cartItems, 'shipping_amount' => 0];
565 }
566 return 0;
567 }
568
569 $totalShippingAmount = 0;
570
571 // Preload all variations for weight calculation (avoids N+1 per group)
572 $allVariationIds = [];
573 foreach ($groups as $group) {
574 foreach ($group['items'] as $item) {
575 $vId = Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
576 if ($vId) $allVariationIds[] = $vId;
577 }
578 }
579 $allVariationIds = array_unique(array_filter($allVariationIds));
580 $allVariationsMap = !empty($allVariationIds)
581 ? ProductVariation::query()->whereIn('id', $allVariationIds)->get()->keyBy('id')
582 : new \FluentCart\Framework\Support\Collection();
583
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 }
592
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 );
625 }
626
627 $totalWeight += ($convertedProductWeight + $packageWeight) * Arr::get($item, 'quantity', 1);
628 }
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;
639 }
640 }
641 } else {
642 // per_item
643 $methodBaseRate = Helper::toCent($selectedMethod->amount) * $cartTotalQuantity;
644 }
645
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;
656 $groupMaxClassCharge = 0;
657
658 foreach ($groupItems as &$gItem) {
659 $quantity = Arr::get($gItem, 'quantity', 1);
660
661 // Calculate class surcharge per item
662 $itemClassCharge = 0;
663 if ($classId && $shippingClasses->has($classId)) {
664 $sc = $shippingClasses->get($classId);
665 $factor = $sc->per_item ? $quantity : 1;
666 if ($sc->type === 'percentage') {
667 $itemClassCharge = ($sc->cost / 100) * Arr::get($gItem, 'unit_price', 0) * $factor;
668 } else {
669 $itemClassCharge = Helper::toCent($sc->cost) * $factor;
670 }
671 }
672
673 $gItem['shipping_charge'] = $itemClassCharge;
674 $groupTotalClassCharge += $itemClassCharge;
675 $groupMaxClassCharge = max($groupMaxClassCharge, $itemClassCharge);
676 }
677 unset($gItem);
678
679 $allGroupsClassCharge += $groupTotalClassCharge;
680 $allGroupsMaxClassCharge = max($allGroupsMaxClassCharge, $groupMaxClassCharge);
681
682 $group['items'] = $groupItems;
683 $group['class_charge'] = $groupTotalClassCharge;
684 }
685 unset($group);
686
687 // Compute total: base rate (once) + class surcharges
688 if ($classAggregation === 'highest_class') {
689 $totalShippingAmount = $methodBaseRate + $allGroupsMaxClassCharge;
690 } else {
691 $totalShippingAmount = $methodBaseRate + $allGroupsClassCharge;
692 }
693
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 }
699
700 $methodOnlyAmount = $methodBaseRate;
701 $distributed = 0;
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
712 foreach ($groups as $groupKey => &$group) {
713 $groupItems = $group['items'];
714 foreach ($groupItems as $idx => &$gItem) {
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);
719 } else {
720 $share = $itemCount > 0 ? (int) round($methodOnlyAmount / $itemCount) : 0;
721 }
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;
726 $distributed += $share;
727 }
728 unset($gItem);
729 $group['items'] = $groupItems;
730 $group['amount'] = $group['class_charge'];
731 }
732 unset($group);
733
734 // Merge group items back into cartItems
735 foreach ($groups as $group) {
736 foreach ($group['keys'] as $i => $key) {
737 if (isset($group['items'][$i])) {
738 $cartItems[$key]['shipping_charge'] = Arr::get($group['items'][$i], 'shipping_charge', 0);
739 $cartItems[$key]['itemwise_shipping_charge'] = Arr::get($group['items'][$i], 'itemwise_shipping_charge', 0);
740 }
741 }
742 }
743
744 if ($returnType === 'items') {
745 return [
746 'items' => $cartItems,
747 'shipping_amount' => $totalShippingAmount
748 ];
749 }
750
751 return $totalShippingAmount;
752 }
753
754 public static function resetShippingCharge()
755 {
756 $cart = CartHelper::getCart();
757 $items = $cart->cart_data;
758 foreach ($items as $key => $item) {
759 $items[$key]['shipping_charge'] = 0;
760 $items[$key]['itemwise_shipping_charge'] = 0;
761 }
762 $cart->cart_data = $items;
763
764 $cart->checkout_data = array_merge($cart->checkout_data, [
765 'shipping_data' => [
766 'shipping_method_id' => null,
767 'shipping_charge' => 0
768 ]
769 ]);
770
771 $cart->save();
772
773 do_action('fluent_cart/checkout/shipping_data_changed', [
774 'cart' => $cart
775 ]);
776 }
777
778 public static function generateCartFromVariation(ProductVariation $variation, $quantity = 1): Cart
779 {
780 $cart = new Cart();
781 $cart->cart_data = [
782 static::generateCartItemFromVariation($variation, $quantity)
783 ];
784
785 $cart = static::addCommonCartData($cart);
786 return $cart;
787 }
788
789 public static function addCommonCartData(Cart $cart)
790 {
791 if (is_user_logged_in()) {
792 $wpUser = wp_get_current_user();
793 $cart->user_id = get_current_user_id();
794 $customer = Customer::query()->where('email', wp_get_current_user()->user_email)->first();
795 if ($customer) {
796 $cart->customer_id = $customer->id;
797 }
798 $cart->email = $wpUser->user_email;
799 $cart->first_name = $wpUser->first_name;
800 $cart->last_name = $wpUser->last_name;
801 $cart->ip_address = AddressHelper::getIpAddress();
802 $cart->user_agent = AddressHelper::getUserAgent();
803 }
804
805 return $cart;
806 }
807
808 public static function generateCartFromCustomVariation(array $variation, $quantity = 1): Cart
809 {
810 $cart = new Cart();
811 $cart->cart_data = [
812 static::generateCartItemCustomItem($variation, $quantity)
813 ];
814 return $cart;
815 }
816
817 /**
818 * Normalize custom item fields to standard cart variation format.
819 *
820 * NOTE:
821 * - Custom items may originate from external sources (filters, adjustments, migrations)
822 * - Some sources provide `id`, others only provide `item_id`
823 * - For cart consistency, `id` is required and will fall back to `item_id` when missing
824 * - This method intentionally mutates the provided object to normalize field names.
825 * The variation object is treated as a transient data structure and is not reused
826 * elsewhere after normalization.
827 *
828 * @param object $variation
829 * @return object
830 */
831 public static function normalizeCustomFields(object $variation): object
832 {
833 // Map custom fields to native fields only if they exist
834 $variation->id = $variation->id ?? $variation->item_id;
835 $variation->item_price = $variation->item_price
836 ?? $variation->unit_price
837 ?? $variation->price
838 ?? 0;
839 $variation->variation_title = $variation->title ?? ($variation->variation_title ?? '');
840
841
842 // Fallbacks
843 $variation->post_id = $variation->post_id ?? 0;
844 $variation->object_id = $variation->object_id ?? $variation->id;
845 $variation->unit_price = $variation->unit_price
846 ?? $variation->item_price
847 ?? $variation->price
848 ?? 0;
849
850 // Payment & fulfillment
851 $variation->payment_type = sanitize_text_field($variation->payment_type ?? 'onetime');
852 $variation->fulfillment_type = sanitize_text_field($variation->fulfillment_type ?? 'digital');
853
854 // Other info
855 if (!empty($variation->other_info) && is_array($variation->other_info)) {
856 $variation->other_info = $variation->other_info;
857 } else {
858 $variation->other_info = [];
859 }
860
861 // Add custom flags
862 $variation->other_info['is_custom'] = $variation->is_custom ?? false;
863 $variation->other_info['view_url'] = $variation->view_url ?? '';
864
865 return $variation;
866 }
867
868
869 /**
870 * @param ProductVariation $variation
871 * @param int|string $updatedQuantity
872 * @return bool
873 */
874 public static function shouldAddItemToCart(ProductVariation $variation, $updatedQuantity): bool
875 {
876 if ($variation->manage_stock == 0) {
877 return true;
878 }
879 return $updatedQuantity <= $variation->available;
880 }
881
882 public static function doingInstantCheckout()
883 {
884 $variationId = App::request()->get(Helper::INSTANT_CHECKOUT_URL_PARAM);
885 if (empty($variationId)) {
886 return false;
887 }
888 return $variationId;
889 }
890
891 /**
892 * @param \FluentCart\App\Models\Cart $cart
893 * @param array|\WP_Error $methods
894 * @param string|int|null $currentSelectedId
895 * @return string|int|null
896 */
897 public static function resolveAutoSelectShippingMethod($cart, $methods, $currentSelectedId)
898 {
899 if ($currentSelectedId || empty($methods) || is_wp_error($methods) || count($methods) !== 1) {
900 return $currentSelectedId;
901 }
902
903 $method = $methods[0];
904
905 $shouldAutoSelect = apply_filters('fluent_cart/shipping/auto_select_single_method', true, [
906 'cart' => $cart,
907 'method' => $method,
908 ]);
909
910 if (!$shouldAutoSelect) {
911 return $currentSelectedId;
912 }
913
914 $charge = static::calculateShippingMethodCharge($method, $cart->cart_data);
915
916 $cart->checkout_data = array_merge(
917 (array) $cart->checkout_data,
918 [
919 'shipping_data' => [
920 'shipping_method_id' => $method->id,
921 'shipping_charge' => is_array($charge) ? Arr::get($charge, 'shipping_amount', 0) : $charge,
922 ],
923 ]
924 );
925 $cart->save();
926
927 return $method->id;
928 }
929 }
930