PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.27
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.27
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
fluent-cart / app / Helpers / CartHelper.php

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

846 lines 32.8 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 'variation_type' => $variation['product_detail']['variation_type'],
67 'is_custom' => false,
68 ], $variation->toArray());
69
70 $cartItem = Arr::only($data, [
71 'id',
72 'object_id',
73 'post_id',
74 'quantity',
75 'post_title',
76 'title',
77 'price',
78 'unit_price',
79 'coupon_discount',
80 'fulfillment_type',
81 'featured_media',
82 'other_info',
83 'cost',
84 'view_url',
85 'line_total_formatted',
86 'line_total',
87 'subtotal',
88 'total',
89 'variation_type',
90 'is_custom'
91 ]);
92
93 // $cartItem['shipping_charge'] = $shippingCharge;
94
95 return $cartItem;
96 }
97
98 public static function generateCartItemCustomItem(array $variation, $quantity = 1): array
99 {
100 //Need to test and check this toArray Issue
101 $data = wp_parse_args(
102 [
103 'quantity' => $quantity,
104 'price' => Arr::get($variation, 'item_price'),
105 'unit_price' => Arr::get($variation, 'item_price'),
106 'object_id' => Arr::get($variation, 'id'),
107 'tax_amount' => Arr::get($variation, 'tax_amount', 0),
108 'title' => Arr::get($variation, 'variation_title'),
109 'post_title' => Arr::get($variation, 'post_title'),
110 'cost' => Arr::get($variation, 'item_cost', 0),
111 'featured_media' => Arr::get($variation, 'featured_media'),
112 'view_url' => Arr::get($variation, 'view_url'),
113 'variation_type' => Arr::get($variation, 'variation_type'),
114 'is_custom' => Arr::get($variation, 'is_custom', false),
115 ],
116 $variation
117 );
118
119 $manualDiscount = Arr::get($data, 'manual_discount', 0);
120 $couponDiscount = Arr::get($data, 'coupon_discount', 0);
121 $discountTotal = $manualDiscount + $couponDiscount;
122 $itemPrice = Arr::get($data, 'item_price', 0);
123 if (!is_numeric($itemPrice)) {
124 $itemPrice = 0;
125 }
126 $subtotal = $itemPrice * Arr::get($data, 'quantity');
127
128 $data['subtotal'] = $subtotal;
129 $data['manual_discount'] = $manualDiscount;
130 $data['coupon_discount'] = $couponDiscount;
131 $data['discount_total'] = $discountTotal;
132 $data['line_total'] = $subtotal - $discountTotal;
133
134 $cartItem = Arr::only($data, [
135 'id',
136 'object_id',
137 'post_id',
138 'quantity',
139 'post_title',
140 'title',
141 'price',
142 'unit_price',
143 'manual_discount',
144 'coupon_discount',
145 'discount_total',
146 'fulfillment_type',
147 'featured_media',
148 'other_info',
149 'cost',
150 'view_url',
151 'line_total',
152 'subtotal',
153 'total',
154 'variation_type',
155 'is_custom'
156 ]);
157
158 return $cartItem;
159 }
160
161 public static function calculateShippingCharge(ProductVariation $variation, int $quantity = 1)
162 {
163
164 if ($variation->fulfillment_type !== 'physical') {
165 return 0;
166 }
167
168 $shippingClass = $variation->shippingClass;
169
170 if (!$shippingClass) {
171 return 0;
172 }
173
174 $factor = empty($shippingClass->per_item) ? 1 : $quantity;
175 if ($shippingClass->type === 'percentage') {
176 return ($shippingClass->cost / 100) * $variation->item_price * $factor;
177 }
178
179 return ($shippingClass->cost * 100) * $factor;
180 }
181
182 private static function itemHasFreeShipping($item): bool
183 {
184 return Arr::get($item, 'other_info.free_shipping', 'no') === 'yes';
185 }
186
187 private static function excludeFreeShippingPhysicalItems(array &$items, array &$physicalItems): void
188 {
189 foreach ($physicalItems as $key => $item) {
190 if (static::itemHasFreeShipping($item)) {
191 $items[$key]['shipping_charge'] = 0;
192 $items[$key]['itemwise_shipping_charge'] = 0;
193 unset($physicalItems[$key]);
194 }
195 }
196 }
197
198 public static function calculateShippingMethodCharge(ShippingMethod $method, ?array $items = null, $returnType = 'amount')
199 {
200 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;
209 $isUsingCart = false;
210
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 if ($items === null) {
225 $isUsingCart = true;
226 $items = static::getCart()->cart_data ?? [];
227 }
228
229 if ($method->type === 'free_shipping') {
230 if ($returnType === 'items') {
231 if ($items === null) {
232 $items = static::getCart()->cart_data ?? [];
233 }
234 foreach ($items as $key => $item) {
235 $items[$key]['shipping_charge'] = 0;
236 $items[$key]['itemwise_shipping_charge'] = 0;
237 }
238 return [
239 'items' => $items,
240 'shipping_amount' => 0
241 ];
242 }
243 return 0;
244 }
245
246 $totalItemWiseShippingCharge = 0;
247
248 $cartCheckoutService = new CheckoutService($items);
249 $isAllDigital = $cartCheckoutService->isAllDigital();
250 $physicalItems = $cartCheckoutService->physicalItems;
251
252 // Exclude only physical items marked for free shipping from charge calculation.
253 static::excludeFreeShippingPhysicalItems($items, $physicalItems);
254
255 if (!$onceCalculated) {
256 $onceCalculated = true;
257 $productIds = array_unique(array_column($physicalItems, 'post_id'));
258 $products = Product::query()->whereIn('ID', $productIds)
259 ->with(['detail'])
260 ->get()
261 ->keyBy('ID');
262
263 $shippingClassIds = $products->pluck('detail.other_info.shipping_class')->filter(function ($item) {
264 return !empty($item);
265 })->toArray();
266
267 $shippingClasses = ShippingClass::query()->whereIn('id', $shippingClassIds)->get()->keyBy('id');
268
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;
273
274 $product = $products->get(Arr::get($item, 'post_id'));
275
276
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 );
282
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 }
291 }
292 }
293 $item['shipping_charge'] = $itemShippingCharge;
294 $totalShippingCharge += $itemShippingCharge;
295
296 $items[$key] = $item;
297 $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
298 }
299
300 $totalItemWiseShippingCharge = $totalShippingCharge;
301 }
302
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 $settings = Arr::wrap($method->settings);
319 $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
320 $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
321
322 if ($configureRate === 'per_order') {
323 $shippingMethodAmount = $method->amount * 100;
324 } else if ($configureRate === 'per_price') {
325 $shippingMethodAmount = $totalItemPrice * ($method->amount / 100);
326 } else if ($configureRate === 'per_weight') {
327 // Sum (product weight + package weight) * quantity for all physical items
328 $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
329 $totalWeight = 0;
330
331 // Batch-load all variations to avoid N+1
332 $variationIds = array_filter(array_map(function ($item) {
333 return Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
334 }, $physicalItems));
335 $variationsMap = $variationIds ? ProductVariation::query()->whereIn('id', $variationIds)->get()->keyBy('id') : new \FluentCart\Framework\Support\Collection();
336
337 foreach ($physicalItems as $item) {
338 $variationId = Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
339 if ($variationId) {
340 $variation = $variationsMap->get($variationId);
341 if ($variation) {
342 $otherInfo = $variation->other_info ?: [];
343 $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
344 $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
345
346 // Convert product weight to store unit
347 $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
348
349 // Add package weight
350 $packageSlug = Arr::get($otherInfo, 'package_slug', '');
351 $package = Helper::getPackageBySlug($packageSlug);
352 $packageWeight = 0;
353 if ($package) {
354 $packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit);
355 $packageWeight = Helper::convertWeight(
356 floatval(Arr::get($package, 'weight', 0)),
357 $packageWeightUnit,
358 $storeWeightUnit
359 );
360 }
361
362 $totalWeight += ($convertedProductWeight + $packageWeight) * Arr::get($item, 'quantity', 1);
363 }
364 }
365 }
366
367 // Look up matching tier from weight_tiers
368 $weightTiers = Arr::get($settings, 'weight_tiers', []);
369 $shippingMethodAmount = 0;
370 foreach ($weightTiers as $tier) {
371 $min = floatval(Arr::get($tier, 'min', 0));
372 $max = floatval(Arr::get($tier, 'max', 0));
373 $cost = floatval(Arr::get($tier, 'cost', 0));
374
375 if ($totalWeight >= $min && ($max <= 0 || $totalWeight <= $max)) {
376 $shippingMethodAmount = Helper::toCent($cost);
377 break;
378 }
379 }
380 } else {
381 $shippingMethodAmount = $method->amount * $totalQuantity * 100;
382 }
383
384 if ($classAggregation === 'highest_class') {
385 $shippingMethodAmount += $maxShippingCharge;
386 } else {
387 $shippingMethodAmount += $totalShippingCharge;
388 }
389
390 $remainingShippingMethodAmount = ($shippingMethodAmount - $totalItemWiseShippingCharge);
391
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);
398
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 }
406 } 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 }
412 }
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 }
419 }
420
421 if ($isUsingCart) {
422 $cart = CartHelper::getCart();
423 $cart->cart_data = $items;
424 $cart->save();
425
426 do_action('fluent_cart/checkout/shipping_data_changed', [
427 'cart' => $cart
428 ]);
429 }
430
431 if ($returnType === 'items') {
432 return [
433 'items' => $items,
434 'shipping_amount' => $shippingMethodAmount
435 ];
436 }
437
438 return $shippingMethodAmount;
439 }
440
441 /**
442 * Calculate shipping charges using the profile-based approach.
443 * Groups cart items by shipping class, finds applicable methods per profile,
444 * falls back to General zones when no class-specific zones exist.
445 *
446 * @param int $shippingMethodId The selected shipping method ID
447 * @param array $cartItems Cart items
448 * @param string $country Country code
449 * @param string|null $state State code
450 * @param string $returnType 'amount' or 'items'
451 * @return int|array
452 */
453 public static function calculateShippingByProfile($shippingMethodId, $cartItems, $country, $state = null, $returnType = 'amount')
454 {
455 $cartCheckoutService = new CheckoutService($cartItems);
456 $isAllDigital = $cartCheckoutService->isAllDigital();
457 $physicalItems = $cartCheckoutService->physicalItems;
458
459 // Exclude only physical items marked for free shipping from profile-based charges.
460 static::excludeFreeShippingPhysicalItems($cartItems, $physicalItems);
461
462 // No shipping is charged for all-digital carts or when every physical item has free shipping.
463 if ($isAllDigital || empty($physicalItems)) {
464 if ($returnType === 'items') {
465 foreach ($cartItems as $key => $item) {
466 $cartItems[$key]['shipping_charge'] = 0;
467 $cartItems[$key]['itemwise_shipping_charge'] = 0;
468 }
469 return ['items' => $cartItems, 'shipping_amount' => 0];
470 }
471 return 0;
472 }
473
474 // Load products with details
475 $productIds = array_unique(array_column($physicalItems, 'post_id'));
476 $products = Product::query()->whereIn('ID', $productIds)
477 ->with(['detail'])
478 ->get()
479 ->keyBy('ID');
480
481 // Group physical items by shipping_class_id (null = General group)
482 $groups = [];
483 foreach ($physicalItems as $key => $item) {
484 $product = $products->get(Arr::get($item, 'post_id'));
485 $classId = null;
486 if ($product && isset($product->detail->other_info['shipping_class'])) {
487 $classId = $product->detail->other_info['shipping_class'] ?: null;
488 }
489 $groupKey = $classId ?: 'general';
490 if (!isset($groups[$groupKey])) {
491 $groups[$groupKey] = [
492 'class_id' => $classId,
493 'items' => [],
494 'keys' => []
495 ];
496 }
497 $groups[$groupKey]['items'][] = $item;
498 $groups[$groupKey]['keys'][] = $key;
499 }
500
501 // Load shipping classes for surcharge calculation
502 $classIds = array_filter(array_column($groups, 'class_id'));
503 $shippingClasses = !empty($classIds)
504 ? ShippingClass::query()->whereIn('id', $classIds)->get()->keyBy('id')
505 : new \FluentCart\Framework\Support\Collection();
506
507 $selectedMethod = ShippingMethod::find($shippingMethodId);
508 if (!$selectedMethod) {
509 if ($returnType === 'items') {
510 return ['items' => $cartItems, 'shipping_amount' => 0];
511 }
512 return 0;
513 }
514
515 // Early return for free_shipping — no base rate, no class surcharges
516 if ($selectedMethod->type === 'free_shipping') {
517 if ($returnType === 'items') {
518 foreach ($cartItems as $key => $item) {
519 $cartItems[$key]['shipping_charge'] = 0;
520 $cartItems[$key]['itemwise_shipping_charge'] = 0;
521 }
522 return ['items' => $cartItems, 'shipping_amount' => 0];
523 }
524 return 0;
525 }
526
527 $totalShippingAmount = 0;
528
529 // Preload all variations for weight calculation (avoids N+1 per group)
530 $allVariationIds = [];
531 foreach ($groups as $group) {
532 foreach ($group['items'] as $item) {
533 $vId = Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
534 if ($vId) $allVariationIds[] = $vId;
535 }
536 }
537 $allVariationIds = array_unique(array_filter($allVariationIds));
538 $allVariationsMap = !empty($allVariationIds)
539 ? ProductVariation::query()->whereIn('id', $allVariationIds)->get()->keyBy('id')
540 : new \FluentCart\Framework\Support\Collection();
541
542 // Compute cart-wide totals BEFORE the group loop (for per_order/per_price/per_weight base rate)
543 $cartTotalPrice = 0;
544 $cartTotalQuantity = 0;
545 foreach ($physicalItems as $item) {
546 $quantity = Arr::get($item, 'quantity', 1);
547 $cartTotalQuantity += $quantity;
548 $cartTotalPrice += ($quantity * Arr::get($item, 'unit_price', 0)) - Arr::get($item, 'discount_total', 0);
549 }
550
551 // Calculate the method base rate ONCE using cart-wide totals
552 $settings = Arr::wrap($selectedMethod->settings);
553 $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
554 $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
555
556 if ($configureRate === 'per_order') {
557 $methodBaseRate = Helper::toCent($selectedMethod->amount);
558 } elseif ($configureRate === 'per_price') {
559 $methodBaseRate = $cartTotalPrice * ($selectedMethod->amount / 100);
560 } elseif ($configureRate === 'per_weight') {
561 $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
562 $totalWeight = 0;
563
564 foreach ($physicalItems as $item) {
565 $varId = Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
566 $variation = $varId ? $allVariationsMap->get($varId) : null;
567 if ($variation) {
568 $otherInfo = $variation->other_info ?: [];
569 $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
570 $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
571 $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
572
573 $packageSlug = Arr::get($otherInfo, 'package_slug', '');
574 $package = Helper::getPackageBySlug($packageSlug);
575 $packageWeight = 0;
576 if ($package) {
577 $packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit);
578 $packageWeight = Helper::convertWeight(
579 floatval(Arr::get($package, 'weight', 0)),
580 $packageWeightUnit,
581 $storeWeightUnit
582 );
583 }
584
585 $totalWeight += ($convertedProductWeight + $packageWeight) * Arr::get($item, 'quantity', 1);
586 }
587 }
588
589 $weightTiers = Arr::get($settings, 'weight_tiers', []);
590 $methodBaseRate = 0;
591 foreach ($weightTiers as $tier) {
592 $min = floatval(Arr::get($tier, 'min', 0));
593 $max = floatval(Arr::get($tier, 'max', 0));
594 if ($totalWeight >= $min && ($max <= 0 || $totalWeight <= $max)) {
595 $methodBaseRate = Helper::toCent(floatval(Arr::get($tier, 'cost', 0)));
596 break;
597 }
598 }
599 } else {
600 // per_item
601 $methodBaseRate = Helper::toCent($selectedMethod->amount) * $cartTotalQuantity;
602 }
603
604 // Accumulate class surcharges across all groups
605 $allGroupsClassCharge = 0;
606 $allGroupsMaxClassCharge = 0;
607
608 foreach ($groups as $groupKey => &$group) {
609 $classId = $group['class_id'];
610 $groupItems = $group['items'];
611
612 // Calculate class surcharges for this group
613 $groupTotalClassCharge = 0;
614 $groupMaxClassCharge = 0;
615
616 foreach ($groupItems as &$gItem) {
617 $quantity = Arr::get($gItem, 'quantity', 1);
618
619 // Calculate class surcharge per item
620 $itemClassCharge = 0;
621 if ($classId && $shippingClasses->has($classId)) {
622 $sc = $shippingClasses->get($classId);
623 $factor = $sc->per_item ? $quantity : 1;
624 if ($sc->type === 'percentage') {
625 $itemClassCharge = ($sc->cost / 100) * Arr::get($gItem, 'unit_price', 0) * $factor;
626 } else {
627 $itemClassCharge = Helper::toCent($sc->cost) * $factor;
628 }
629 }
630
631 $gItem['shipping_charge'] = $itemClassCharge;
632 $groupTotalClassCharge += $itemClassCharge;
633 $groupMaxClassCharge = max($groupMaxClassCharge, $itemClassCharge);
634 }
635 unset($gItem);
636
637 $allGroupsClassCharge += $groupTotalClassCharge;
638 $allGroupsMaxClassCharge = max($allGroupsMaxClassCharge, $groupMaxClassCharge);
639
640 $group['items'] = $groupItems;
641 $group['class_charge'] = $groupTotalClassCharge;
642 }
643 unset($group);
644
645 // Compute total: base rate (once) + class surcharges
646 if ($classAggregation === 'highest_class') {
647 $totalShippingAmount = $methodBaseRate + $allGroupsMaxClassCharge;
648 } else {
649 $totalShippingAmount = $methodBaseRate + $allGroupsClassCharge;
650 }
651
652 // Distribute the total shipping amount across all physical items proportionally
653 $totalLineTotal = 0;
654 foreach ($physicalItems as $item) {
655 $totalLineTotal += Arr::get($item, 'line_total', 0);
656 }
657
658 $methodOnlyAmount = $methodBaseRate;
659 $distributed = 0;
660 $itemCount = count($physicalItems);
661 foreach ($groups as $groupKey => &$group) {
662 $groupItems = $group['items'];
663 foreach ($groupItems as $idx => &$gItem) {
664 if ($totalLineTotal > 0) {
665 $share = (Arr::get($gItem, 'line_total', 0) / $totalLineTotal) * $methodOnlyAmount;
666 } else {
667 $share = $itemCount > 0 ? ($methodOnlyAmount / $itemCount) : 0;
668 }
669 $share = round($share, 2);
670 $gItem['itemwise_shipping_charge'] = ceil($share) + Arr::get($gItem, 'shipping_charge', 0);
671 $distributed += $share;
672 }
673 unset($gItem);
674 $group['items'] = $groupItems;
675 $group['amount'] = $group['class_charge'];
676 }
677 unset($group);
678
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 // Merge group items back into cartItems
690 foreach ($groups as $group) {
691 foreach ($group['keys'] as $i => $key) {
692 if (isset($group['items'][$i])) {
693 $cartItems[$key]['shipping_charge'] = Arr::get($group['items'][$i], 'shipping_charge', 0);
694 $cartItems[$key]['itemwise_shipping_charge'] = Arr::get($group['items'][$i], 'itemwise_shipping_charge', 0);
695 }
696 }
697 }
698
699 if ($returnType === 'items') {
700 return [
701 'items' => $cartItems,
702 'shipping_amount' => $totalShippingAmount
703 ];
704 }
705
706 return $totalShippingAmount;
707 }
708
709 public static function resetShippingCharge()
710 {
711 $cart = CartHelper::getCart();
712 $items = $cart->cart_data;
713 foreach ($items as $key => $item) {
714 $items[$key]['shipping_charge'] = 0;
715 $items[$key]['itemwise_shipping_charge'] = 0;
716 }
717 $cart->cart_data = $items;
718
719 $cart->checkout_data = array_merge($cart->checkout_data, [
720 'shipping_data' => [
721 'shipping_method_id' => null,
722 'shipping_charge' => 0
723 ]
724 ]);
725
726 $cart->save();
727
728 do_action('fluent_cart/checkout/shipping_data_changed', [
729 'cart' => $cart
730 ]);
731 }
732
733 public static function generateCartFromVariation(ProductVariation $variation, $quantity = 1): Cart
734 {
735 $cart = new Cart();
736 $cart->cart_data = [
737 static::generateCartItemFromVariation($variation, $quantity)
738 ];
739
740 $cart = static::addCommonCartData($cart);
741 return $cart;
742 }
743
744 public static function addCommonCartData(Cart $cart)
745 {
746 if (is_user_logged_in()) {
747 $wpUser = wp_get_current_user();
748 $cart->user_id = get_current_user_id();
749 $customer = Customer::query()->where('email', wp_get_current_user()->user_email)->first();
750 if ($customer) {
751 $cart->customer_id = $customer->id;
752 }
753 $cart->email = $wpUser->user_email;
754 $cart->first_name = $wpUser->first_name;
755 $cart->last_name = $wpUser->last_name;
756 $cart->ip_address = AddressHelper::getIpAddress();
757 $cart->user_agent = AddressHelper::getUserAgent();
758 }
759
760 return $cart;
761 }
762
763 public static function generateCartFromCustomVariation(array $variation, $quantity = 1): Cart
764 {
765 $cart = new Cart();
766 $cart->cart_data = [
767 static::generateCartItemCustomItem($variation, $quantity)
768 ];
769 return $cart;
770 }
771
772 /**
773 * Normalize custom item fields to standard cart variation format.
774 *
775 * NOTE:
776 * - Custom items may originate from external sources (filters, adjustments, migrations)
777 * - Some sources provide `id`, others only provide `item_id`
778 * - For cart consistency, `id` is required and will fall back to `item_id` when missing
779 * - This method intentionally mutates the provided object to normalize field names.
780 * The variation object is treated as a transient data structure and is not reused
781 * elsewhere after normalization.
782 *
783 * @param object $variation
784 * @return object
785 */
786 public static function normalizeCustomFields(object $variation): object
787 {
788 // Map custom fields to native fields only if they exist
789 $variation->id = $variation->id ?? $variation->item_id;
790 $variation->item_price = $variation->item_price
791 ?? $variation->unit_price
792 ?? $variation->price
793 ?? 0;
794 $variation->variation_title = $variation->title ?? ($variation->variation_title ?? '');
795
796
797 // Fallbacks
798 $variation->post_id = $variation->post_id ?? 0;
799 $variation->object_id = $variation->object_id ?? $variation->id;
800 $variation->unit_price = $variation->unit_price
801 ?? $variation->item_price
802 ?? $variation->price
803 ?? 0;
804
805 // Payment & fulfillment
806 $variation->payment_type = sanitize_text_field($variation->payment_type ?? 'onetime');
807 $variation->fulfillment_type = sanitize_text_field($variation->fulfillment_type ?? 'digital');
808
809 // Other info
810 if (!empty($variation->other_info) && is_array($variation->other_info)) {
811 $variation->other_info = $variation->other_info;
812 } else {
813 $variation->other_info = [];
814 }
815
816 // Add custom flags
817 $variation->other_info['is_custom'] = $variation->is_custom ?? false;
818 $variation->other_info['view_url'] = $variation->view_url ?? '';
819
820 return $variation;
821 }
822
823
824 /**
825 * @param ProductVariation $variation
826 * @param int|string $updatedQuantity
827 * @return bool
828 */
829 public static function shouldAddItemToCart(ProductVariation $variation, $updatedQuantity): bool
830 {
831 if ($variation->manage_stock == 0) {
832 return true;
833 }
834 return $updatedQuantity <= $variation->available;
835 }
836
837 public static function doingInstantCheckout()
838 {
839 $variationId = App::request()->get(Helper::INSTANT_CHECKOUT_URL_PARAM);
840 if (empty($variationId)) {
841 return false;
842 }
843 return $variationId;
844 }
845 }
846