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

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