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

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