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

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

887 lines 34.1 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 $shippingMethodAmount = (int)round($shippingMethodAmount);
391
392 $remainingShippingMethodAmount = ($shippingMethodAmount - $totalItemWiseShippingCharge);
393
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);
400
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 }
408 } 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 }
414 }
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 }
421 }
422
423 if ($isUsingCart) {
424 $cart = CartHelper::getCart();
425 $cart->cart_data = $items;
426 $cart->save();
427
428 do_action('fluent_cart/checkout/shipping_data_changed', [
429 'cart' => $cart
430 ]);
431 }
432
433 if ($returnType === 'items') {
434 return [
435 'items' => $items,
436 'shipping_amount' => $shippingMethodAmount
437 ];
438 }
439
440 return $shippingMethodAmount;
441 }
442
443 /**
444 * Calculate shipping charges using the profile-based approach.
445 * Groups cart items by shipping class, finds applicable methods per profile,
446 * falls back to General zones when no class-specific zones exist.
447 *
448 * @param int $shippingMethodId The selected shipping method ID
449 * @param array $cartItems Cart items
450 * @param string $country Country code
451 * @param string|null $state State code
452 * @param string $returnType 'amount' or 'items'
453 * @return int|array
454 */
455 public static function calculateShippingByProfile($shippingMethodId, $cartItems, $country, $state = null, $returnType = 'amount')
456 {
457 $cartCheckoutService = new CheckoutService($cartItems);
458 $isAllDigital = $cartCheckoutService->isAllDigital();
459 $physicalItems = $cartCheckoutService->physicalItems;
460
461 // Exclude only physical items marked for free shipping from profile-based charges.
462 static::excludeFreeShippingPhysicalItems($cartItems, $physicalItems);
463
464 // No shipping is charged for all-digital carts or when every physical item has free shipping.
465 if ($isAllDigital || empty($physicalItems)) {
466 if ($returnType === 'items') {
467 foreach ($cartItems as $key => $item) {
468 $cartItems[$key]['shipping_charge'] = 0;
469 $cartItems[$key]['itemwise_shipping_charge'] = 0;
470 }
471 return ['items' => $cartItems, 'shipping_amount' => 0];
472 }
473 return 0;
474 }
475
476 // Load products with details
477 $productIds = array_unique(array_column($physicalItems, 'post_id'));
478 $products = Product::query()->whereIn('ID', $productIds)
479 ->with(['detail'])
480 ->get()
481 ->keyBy('ID');
482
483 // Group physical items by shipping_class_id (null = General group)
484 $groups = [];
485 foreach ($physicalItems as $key => $item) {
486 $product = $products->get(Arr::get($item, 'post_id'));
487 $classId = null;
488 if ($product && isset($product->detail->other_info['shipping_class'])) {
489 $classId = $product->detail->other_info['shipping_class'] ?: null;
490 }
491 $groupKey = $classId ?: 'general';
492 if (!isset($groups[$groupKey])) {
493 $groups[$groupKey] = [
494 'class_id' => $classId,
495 'items' => [],
496 'keys' => []
497 ];
498 }
499 $groups[$groupKey]['items'][] = $item;
500 $groups[$groupKey]['keys'][] = $key;
501 }
502
503 // Load shipping classes for surcharge calculation
504 $classIds = array_filter(array_column($groups, 'class_id'));
505 $shippingClasses = !empty($classIds)
506 ? ShippingClass::query()->whereIn('id', $classIds)->get()->keyBy('id')
507 : new \FluentCart\Framework\Support\Collection();
508
509 $selectedMethod = ShippingMethod::find($shippingMethodId);
510 if (!$selectedMethod) {
511 if ($returnType === 'items') {
512 return ['items' => $cartItems, 'shipping_amount' => 0];
513 }
514 return 0;
515 }
516
517 // Early return for free_shipping — no base rate, no class surcharges
518 if ($selectedMethod->type === 'free_shipping') {
519 if ($returnType === 'items') {
520 foreach ($cartItems as $key => $item) {
521 $cartItems[$key]['shipping_charge'] = 0;
522 $cartItems[$key]['itemwise_shipping_charge'] = 0;
523 }
524 return ['items' => $cartItems, 'shipping_amount' => 0];
525 }
526 return 0;
527 }
528
529 $totalShippingAmount = 0;
530
531 // Preload all variations for weight calculation (avoids N+1 per group)
532 $allVariationIds = [];
533 foreach ($groups as $group) {
534 foreach ($group['items'] as $item) {
535 $vId = Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
536 if ($vId) $allVariationIds[] = $vId;
537 }
538 }
539 $allVariationIds = array_unique(array_filter($allVariationIds));
540 $allVariationsMap = !empty($allVariationIds)
541 ? ProductVariation::query()->whereIn('id', $allVariationIds)->get()->keyBy('id')
542 : new \FluentCart\Framework\Support\Collection();
543
544 // Compute cart-wide totals BEFORE the group loop (for per_order/per_price/per_weight base rate)
545 $cartTotalPrice = 0;
546 $cartTotalQuantity = 0;
547 foreach ($physicalItems as $item) {
548 $quantity = Arr::get($item, 'quantity', 1);
549 $cartTotalQuantity += $quantity;
550 $cartTotalPrice += ($quantity * Arr::get($item, 'unit_price', 0)) - Arr::get($item, 'discount_total', 0);
551 }
552
553 // Calculate the method base rate ONCE using cart-wide totals
554 $settings = Arr::wrap($selectedMethod->settings);
555 $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
556 $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
557
558 if ($configureRate === 'per_order') {
559 $methodBaseRate = Helper::toCent($selectedMethod->amount);
560 } elseif ($configureRate === 'per_price') {
561 $methodBaseRate = $cartTotalPrice * ($selectedMethod->amount / 100);
562 } elseif ($configureRate === 'per_weight') {
563 $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
564 $totalWeight = 0;
565
566 foreach ($physicalItems as $item) {
567 $varId = Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
568 $variation = $varId ? $allVariationsMap->get($varId) : null;
569 if ($variation) {
570 $otherInfo = $variation->other_info ?: [];
571 $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
572 $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
573 $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
574
575 $packageSlug = Arr::get($otherInfo, 'package_slug', '');
576 $package = Helper::getPackageBySlug($packageSlug);
577 $packageWeight = 0;
578 if ($package) {
579 $packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit);
580 $packageWeight = Helper::convertWeight(
581 floatval(Arr::get($package, 'weight', 0)),
582 $packageWeightUnit,
583 $storeWeightUnit
584 );
585 }
586
587 $totalWeight += ($convertedProductWeight + $packageWeight) * Arr::get($item, 'quantity', 1);
588 }
589 }
590
591 $weightTiers = Arr::get($settings, 'weight_tiers', []);
592 $methodBaseRate = 0;
593 foreach ($weightTiers as $tier) {
594 $min = floatval(Arr::get($tier, 'min', 0));
595 $max = floatval(Arr::get($tier, 'max', 0));
596 if ($totalWeight >= $min && ($max <= 0 || $totalWeight <= $max)) {
597 $methodBaseRate = Helper::toCent(floatval(Arr::get($tier, 'cost', 0)));
598 break;
599 }
600 }
601 } else {
602 // per_item
603 $methodBaseRate = Helper::toCent($selectedMethod->amount) * $cartTotalQuantity;
604 }
605
606 // Accumulate class surcharges across all groups
607 $allGroupsClassCharge = 0;
608 $allGroupsMaxClassCharge = 0;
609
610 foreach ($groups as $groupKey => &$group) {
611 $classId = $group['class_id'];
612 $groupItems = $group['items'];
613
614 // Calculate class surcharges for this group
615 $groupTotalClassCharge = 0;
616 $groupMaxClassCharge = 0;
617
618 foreach ($groupItems as &$gItem) {
619 $quantity = Arr::get($gItem, 'quantity', 1);
620
621 // Calculate class surcharge per item
622 $itemClassCharge = 0;
623 if ($classId && $shippingClasses->has($classId)) {
624 $sc = $shippingClasses->get($classId);
625 $factor = $sc->per_item ? $quantity : 1;
626 if ($sc->type === 'percentage') {
627 $itemClassCharge = ($sc->cost / 100) * Arr::get($gItem, 'unit_price', 0) * $factor;
628 } else {
629 $itemClassCharge = Helper::toCent($sc->cost) * $factor;
630 }
631 }
632
633 $gItem['shipping_charge'] = $itemClassCharge;
634 $groupTotalClassCharge += $itemClassCharge;
635 $groupMaxClassCharge = max($groupMaxClassCharge, $itemClassCharge);
636 }
637 unset($gItem);
638
639 $allGroupsClassCharge += $groupTotalClassCharge;
640 $allGroupsMaxClassCharge = max($allGroupsMaxClassCharge, $groupMaxClassCharge);
641
642 $group['items'] = $groupItems;
643 $group['class_charge'] = $groupTotalClassCharge;
644 }
645 unset($group);
646
647 // Compute total: base rate (once) + class surcharges
648 if ($classAggregation === 'highest_class') {
649 $totalShippingAmount = $methodBaseRate + $allGroupsMaxClassCharge;
650 } else {
651 $totalShippingAmount = $methodBaseRate + $allGroupsClassCharge;
652 }
653
654 // Distribute the total shipping amount across all physical items proportionally
655 $totalLineTotal = 0;
656 foreach ($physicalItems as $item) {
657 $totalLineTotal += Arr::get($item, 'line_total', 0);
658 }
659
660 $methodOnlyAmount = $methodBaseRate;
661 $distributed = 0;
662 $itemCount = count($physicalItems);
663 foreach ($groups as $groupKey => &$group) {
664 $groupItems = $group['items'];
665 foreach ($groupItems as $idx => &$gItem) {
666 if ($totalLineTotal > 0) {
667 $share = (Arr::get($gItem, 'line_total', 0) / $totalLineTotal) * $methodOnlyAmount;
668 } else {
669 $share = $itemCount > 0 ? ($methodOnlyAmount / $itemCount) : 0;
670 }
671 $share = round($share, 2);
672 $gItem['itemwise_shipping_charge'] = ceil($share) + Arr::get($gItem, 'shipping_charge', 0);
673 $distributed += $share;
674 }
675 unset($gItem);
676 $group['items'] = $groupItems;
677 $group['amount'] = $group['class_charge'];
678 }
679 unset($group);
680
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 // Merge group items back into cartItems
692 foreach ($groups as $group) {
693 foreach ($group['keys'] as $i => $key) {
694 if (isset($group['items'][$i])) {
695 $cartItems[$key]['shipping_charge'] = Arr::get($group['items'][$i], 'shipping_charge', 0);
696 $cartItems[$key]['itemwise_shipping_charge'] = Arr::get($group['items'][$i], 'itemwise_shipping_charge', 0);
697 }
698 }
699 }
700
701 if ($returnType === 'items') {
702 return [
703 'items' => $cartItems,
704 'shipping_amount' => $totalShippingAmount
705 ];
706 }
707
708 return $totalShippingAmount;
709 }
710
711 public static function resetShippingCharge()
712 {
713 $cart = CartHelper::getCart();
714 $items = $cart->cart_data;
715 foreach ($items as $key => $item) {
716 $items[$key]['shipping_charge'] = 0;
717 $items[$key]['itemwise_shipping_charge'] = 0;
718 }
719 $cart->cart_data = $items;
720
721 $cart->checkout_data = array_merge($cart->checkout_data, [
722 'shipping_data' => [
723 'shipping_method_id' => null,
724 'shipping_charge' => 0
725 ]
726 ]);
727
728 $cart->save();
729
730 do_action('fluent_cart/checkout/shipping_data_changed', [
731 'cart' => $cart
732 ]);
733 }
734
735 public static function generateCartFromVariation(ProductVariation $variation, $quantity = 1): Cart
736 {
737 $cart = new Cart();
738 $cart->cart_data = [
739 static::generateCartItemFromVariation($variation, $quantity)
740 ];
741
742 $cart = static::addCommonCartData($cart);
743 return $cart;
744 }
745
746 public static function addCommonCartData(Cart $cart)
747 {
748 if (is_user_logged_in()) {
749 $wpUser = wp_get_current_user();
750 $cart->user_id = get_current_user_id();
751 $customer = Customer::query()->where('email', wp_get_current_user()->user_email)->first();
752 if ($customer) {
753 $cart->customer_id = $customer->id;
754 }
755 $cart->email = $wpUser->user_email;
756 $cart->first_name = $wpUser->first_name;
757 $cart->last_name = $wpUser->last_name;
758 $cart->ip_address = AddressHelper::getIpAddress();
759 $cart->user_agent = AddressHelper::getUserAgent();
760 }
761
762 return $cart;
763 }
764
765 public static function generateCartFromCustomVariation(array $variation, $quantity = 1): Cart
766 {
767 $cart = new Cart();
768 $cart->cart_data = [
769 static::generateCartItemCustomItem($variation, $quantity)
770 ];
771 return $cart;
772 }
773
774 /**
775 * Normalize custom item fields to standard cart variation format.
776 *
777 * NOTE:
778 * - Custom items may originate from external sources (filters, adjustments, migrations)
779 * - Some sources provide `id`, others only provide `item_id`
780 * - For cart consistency, `id` is required and will fall back to `item_id` when missing
781 * - This method intentionally mutates the provided object to normalize field names.
782 * The variation object is treated as a transient data structure and is not reused
783 * elsewhere after normalization.
784 *
785 * @param object $variation
786 * @return object
787 */
788 public static function normalizeCustomFields(object $variation): object
789 {
790 // Map custom fields to native fields only if they exist
791 $variation->id = $variation->id ?? $variation->item_id;
792 $variation->item_price = $variation->item_price
793 ?? $variation->unit_price
794 ?? $variation->price
795 ?? 0;
796 $variation->variation_title = $variation->title ?? ($variation->variation_title ?? '');
797
798
799 // Fallbacks
800 $variation->post_id = $variation->post_id ?? 0;
801 $variation->object_id = $variation->object_id ?? $variation->id;
802 $variation->unit_price = $variation->unit_price
803 ?? $variation->item_price
804 ?? $variation->price
805 ?? 0;
806
807 // Payment & fulfillment
808 $variation->payment_type = sanitize_text_field($variation->payment_type ?? 'onetime');
809 $variation->fulfillment_type = sanitize_text_field($variation->fulfillment_type ?? 'digital');
810
811 // Other info
812 if (!empty($variation->other_info) && is_array($variation->other_info)) {
813 $variation->other_info = $variation->other_info;
814 } else {
815 $variation->other_info = [];
816 }
817
818 // Add custom flags
819 $variation->other_info['is_custom'] = $variation->is_custom ?? false;
820 $variation->other_info['view_url'] = $variation->view_url ?? '';
821
822 return $variation;
823 }
824
825
826 /**
827 * @param ProductVariation $variation
828 * @param int|string $updatedQuantity
829 * @return bool
830 */
831 public static function shouldAddItemToCart(ProductVariation $variation, $updatedQuantity): bool
832 {
833 if ($variation->manage_stock == 0) {
834 return true;
835 }
836 return $updatedQuantity <= $variation->available;
837 }
838
839 public static function doingInstantCheckout()
840 {
841 $variationId = App::request()->get(Helper::INSTANT_CHECKOUT_URL_PARAM);
842 if (empty($variationId)) {
843 return false;
844 }
845 return $variationId;
846 }
847
848 /**
849 * @param \FluentCart\App\Models\Cart $cart
850 * @param array|\WP_Error $methods
851 * @param string|int|null $currentSelectedId
852 * @return string|int|null
853 */
854 public static function resolveAutoSelectShippingMethod($cart, $methods, $currentSelectedId)
855 {
856 if ($currentSelectedId || empty($methods) || is_wp_error($methods) || count($methods) !== 1) {
857 return $currentSelectedId;
858 }
859
860 $method = $methods[0];
861
862 $shouldAutoSelect = apply_filters('fluent_cart/shipping/auto_select_single_method', true, [
863 'cart' => $cart,
864 'method' => $method,
865 ]);
866
867 if (!$shouldAutoSelect) {
868 return $currentSelectedId;
869 }
870
871 $charge = static::calculateShippingMethodCharge($method, $cart->cart_data);
872
873 $cart->checkout_data = array_merge(
874 (array) $cart->checkout_data,
875 [
876 'shipping_data' => [
877 'shipping_method_id' => $method->id,
878 'shipping_charge' => is_array($charge) ? Arr::get($charge, 'shipping_amount', 0) : $charge,
879 ],
880 ]
881 );
882 $cart->save();
883
884 return $method->id;
885 }
886 }
887