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

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

987 lines 39.5 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 const MAX_QUANTITY = 100000;
21
22 const QUANTITY_HARD_LIMIT = 10000000;
23
24 /**
25 * The ceiling is what keeps `unit_price * quantity` inside PHP's integer range —
26 * without one the product overflows to a float and casts back to a wrapped total.
27 * Cart updates send a signed delta, so those allow the negative side too.
28 */
29 public static function validateQuantity($quantity, $isDelta = false)
30 {
31 if (!is_numeric($quantity)) {
32 return static::invalidQuantityError();
33 }
34
35 $max = static::maxQuantity();
36 $value = (float)$quantity;
37 $min = $isDelta ? -$max : 1;
38
39 if ($value != floor($value) || $value < $min || $value > $max) {
40 return static::invalidQuantityError();
41 }
42
43 return null;
44 }
45
46 public static function maxQuantity()
47 {
48 /**
49 * Filter the highest quantity a single cart line accepts.
50 *
51 * Return a whole number of one or more. The value is a safety ceiling, not just
52 * a storefront preference: it is what keeps a line's price multiplication inside
53 * PHP's integer range. A value that cannot serve that purpose is ignored — a
54 * non-numeric or fractional value falls back to the default, and anything above
55 * QUANTITY_HARD_LIMIT is capped there.
56 *
57 * @param int $maxQuantity Highest accepted quantity for one cart line.
58 */
59 $max = apply_filters('fluent_cart/cart/max_quantity', self::MAX_QUANTITY);
60
61 if (!is_numeric($max) || (float)$max != floor((float)$max) || $max < 1) {
62 return self::MAX_QUANTITY;
63 }
64
65 return (int)min($max, self::QUANTITY_HARD_LIMIT);
66 }
67
68 private static function invalidQuantityError()
69 {
70 /* translators: %d: the highest quantity a single cart line accepts. */
71 $message = __('Please enter a product quantity between 1 and %d.', 'fluent-cart');
72
73 return new \WP_Error('invalid_cart_quantity', sprintf($message, static::maxQuantity()));
74 }
75
76 public static function getCart($hash = null, $create = false)
77 {
78 return CartResource::get([
79 'hash' => $hash ?? App::request()->get(Helper::INSTANT_CHECKOUT_URL_PARAM),
80 'create' => $create
81 ]);
82 }
83
84 public static function generateCartItemFromVariation(ProductVariation $variation, $quantity = 1): array
85 {
86 $mediaUrl = $variation->thumbnail ?: $variation->product->thumbnail;
87
88 // $shippingCharge = static::calculateShippingCharge($variation, $quantity);
89
90 $itemPrice = apply_filters('fluent_cart/cart/item_price', $variation->item_price, [
91 'variation' => $variation,
92 'quantity' => $quantity,
93 ]);
94
95 // Ensure filtered price is a valid non-negative integer (cents)
96 $itemPrice = max(0, (int)$itemPrice);
97
98 $subtotal = $itemPrice * $quantity;
99
100 //Need to test and check this toArray Issue
101 $data = wp_parse_args([
102 'quantity' => $quantity,
103 'price' => $itemPrice,
104 'unit_price' => $itemPrice,
105 'line_total' => $itemPrice * $quantity,
106 'subtotal' => $subtotal,
107 'discount_total' => 0,
108 'tax_total' => 0,
109 'line_total_formatted' => CurrencySettings::getFormattedPrice($subtotal),
110 'object_id' => $variation->id,
111 'title' => $variation->variation_title,
112 'post_title' => $variation->product->post_title,
113 'coupon_discount' => 0,
114 'cost' => $variation->item_cost ?? 0,
115 'featured_media' => $mediaUrl,
116 'view_url' => URL::appendQueryParams(
117 $variation->product->view_url,
118 [
119 'selected' => $variation->id
120 ]
121 ),
122 // Property access lazy-loads when callers didn't eager-load
123 // product_detail (instant-checkout, cart update endpoint) so
124 // CartRenderer can read variation_type to toggle the
125 // variant-title-hidden class on simple products.
126 'variation_type' => $variation->product_detail ? $variation->product_detail->variation_type : '',
127 'is_custom' => false,
128 ], $variation->toArray());
129
130 $cartItem = Arr::only($data, [
131 'id',
132 'object_id',
133 'post_id',
134 'quantity',
135 'post_title',
136 'title',
137 'price',
138 'unit_price',
139 'coupon_discount',
140 'fulfillment_type',
141 'featured_media',
142 'other_info',
143 'cost',
144 'view_url',
145 'line_total_formatted',
146 'line_total',
147 'subtotal',
148 'total',
149 'variation_type',
150 'is_custom'
151 ]);
152
153 // $cartItem['shipping_charge'] = $shippingCharge;
154
155 // Snapshot the variant's attribute set (pa_* + third-party) into
156 // other_info so cart, checkout and the resulting order item all carry a
157 // frozen attribute map that survives later attribute-library renames.
158 $otherInfo = Arr::get($cartItem, 'other_info', []);
159 if (!is_array($otherInfo)) {
160 $otherInfo = [];
161 }
162 $otherInfo['item_attributes'] = AttributeHelper::getProductItemAttributes(
163 $variation->id,
164 $variation->post_id
165 );
166 $cartItem['other_info'] = $otherInfo;
167
168 return $cartItem;
169 }
170
171 public static function generateCartItemCustomItem(array $variation, $quantity = 1): array
172 {
173 //Need to test and check this toArray Issue
174 $data = wp_parse_args(
175 [
176 'quantity' => $quantity,
177 'price' => Arr::get($variation, 'item_price'),
178 'unit_price' => Arr::get($variation, 'item_price'),
179 'object_id' => Arr::get($variation, 'id'),
180 'tax_amount' => Arr::get($variation, 'tax_amount', 0),
181 'title' => Arr::get($variation, 'variation_title'),
182 'post_title' => Arr::get($variation, 'post_title'),
183 'cost' => Arr::get($variation, 'item_cost', 0),
184 'featured_media' => Arr::get($variation, 'featured_media'),
185 'view_url' => Arr::get($variation, 'view_url'),
186 'variation_type' => Arr::get($variation, 'variation_type'),
187 'is_custom' => Arr::get($variation, 'is_custom', false),
188 ],
189 $variation
190 );
191
192 $manualDiscount = Arr::get($data, 'manual_discount', 0);
193 $couponDiscount = Arr::get($data, 'coupon_discount', 0);
194 $discountTotal = $manualDiscount + $couponDiscount;
195 $itemPrice = Arr::get($data, 'item_price', 0);
196 if (!is_numeric($itemPrice)) {
197 $itemPrice = 0;
198 }
199 $subtotal = $itemPrice * Arr::get($data, 'quantity');
200
201 $data['subtotal'] = $subtotal;
202 $data['manual_discount'] = $manualDiscount;
203 $data['coupon_discount'] = $couponDiscount;
204 $data['discount_total'] = $discountTotal;
205 $data['line_total'] = $subtotal - $discountTotal;
206
207 $cartItem = Arr::only($data, [
208 'id',
209 'object_id',
210 'post_id',
211 'quantity',
212 'post_title',
213 'title',
214 'price',
215 'unit_price',
216 'manual_discount',
217 'coupon_discount',
218 'discount_total',
219 'fulfillment_type',
220 'featured_media',
221 'other_info',
222 'cost',
223 'view_url',
224 'line_total',
225 'subtotal',
226 'total',
227 'variation_type',
228 'is_custom'
229 ]);
230
231 return $cartItem;
232 }
233
234 public static function calculateShippingCharge(ProductVariation $variation, int $quantity = 1)
235 {
236
237 if ($variation->fulfillment_type !== 'physical') {
238 return 0;
239 }
240
241 $shippingClass = $variation->shippingClass;
242
243 if (!$shippingClass) {
244 return 0;
245 }
246
247 $factor = empty($shippingClass->per_item) ? 1 : $quantity;
248 if ($shippingClass->type === 'percentage') {
249 return ($shippingClass->cost / 100) * $variation->item_price * $factor;
250 }
251
252 return ($shippingClass->cost * 100) * $factor;
253 }
254
255 private static function itemHasFreeShipping($item): bool
256 {
257 return Arr::get($item, 'other_info.free_shipping', 'no') === 'yes';
258 }
259
260 private static function excludeFreeShippingPhysicalItems(array &$items, array &$physicalItems): void
261 {
262 foreach ($physicalItems as $key => $item) {
263 if (self::itemHasFreeShipping($item)) {
264 $items[$key]['shipping_charge'] = 0;
265 $items[$key]['itemwise_shipping_charge'] = 0;
266 unset($physicalItems[$key]);
267 }
268 }
269 }
270
271 public static function calculateShippingMethodCharge(ShippingMethod $method, ?array $items = null, $returnType = 'amount')
272 {
273 static $onceCalculated = false;
274 static $lastFingerprint = null;
275 static $products = null;
276 static $shippingClasses = null;
277
278 // Per-call locals: $physicalItems/$isAllDigital are rebuilt fresh from $items on every
279 // call (via CheckoutService below), and $totalItemPrice/$totalQuantity/
280 // $totalShippingCharge/$maxShippingCharge are accumulated fresh in the per-item
281 // annotation loop below, so none of them may persist across calls — only the
282 // $products/$shippingClasses DB lookups above are worth caching per request.
283 $totalItemPrice = 0;
284 $totalQuantity = 0;
285 $physicalItems = [];
286 $isAllDigital = false;
287 $maxShippingCharge = 0;
288 $totalShippingCharge = 0;
289 $isUsingCart = false;
290
291 if ($items === null) {
292 $isUsingCart = true;
293 $items = static::getCart()->cart_data ?? [];
294 }
295
296 // Fingerprint the resolved method + items so a same-request call with changed
297 // cart items (e.g. an item added/removed after ShippingModule::handleItemsChanges
298 // re-runs this calc) is never mistaken for a repeat of the previous call. Must be
299 // computed from the RESOLVED $items (post null → cart fallback above), not the raw
300 // argument, otherwise a null-argument call would fingerprint differently from the
301 // cart data it resolves to. Fields: id/object_id/variation_id, quantity, line_total,
302 // free_shipping, post_id, unit_price, discount_total.
303 $fingerprint = md5(serialize([
304 $method->id,
305 array_map(function ($item) {
306 return [
307 Arr::get($item, 'id', Arr::get($item, 'object_id', Arr::get($item, 'variation_id'))),
308 Arr::get($item, 'quantity'),
309 Arr::get($item, 'line_total'),
310 self::itemHasFreeShipping($item) ? 'yes' : 'no',
311 Arr::get($item, 'post_id'),
312 Arr::get($item, 'unit_price'),
313 Arr::get($item, 'discount_total'),
314 ];
315 }, $items),
316 ]));
317
318 // Reset the cached-lookup guard when the method/items fingerprint changes to prevent
319 // stale $products/$shippingClasses from a previous call in the same request (replaces
320 // the old $lastMethodId check, which missed same-method-id calls made with different
321 // items). The per-call locals above are already reinitialized on every call, so only
322 // the "once" guard needs resetting here.
323 if ($lastFingerprint !== $fingerprint) {
324 $onceCalculated = false;
325 $lastFingerprint = $fingerprint;
326 }
327
328 if ($method->type === 'free_shipping') {
329 if ($returnType === 'items') {
330 if ($items === null) {
331 $items = static::getCart()->cart_data ?? [];
332 }
333 foreach ($items as $key => $item) {
334 $items[$key]['shipping_charge'] = 0;
335 $items[$key]['itemwise_shipping_charge'] = 0;
336 }
337 return [
338 'items' => $items,
339 'shipping_amount' => 0
340 ];
341 }
342 return 0;
343 }
344
345 $cartCheckoutService = new CheckoutService($items);
346 $isAllDigital = $cartCheckoutService->isAllDigital();
347 $physicalItems = $cartCheckoutService->physicalItems;
348
349 // Exclude only physical items marked for free shipping from charge calculation.
350 static::excludeFreeShippingPhysicalItems($items, $physicalItems);
351
352 // No shipping is charged for all-digital carts or when every physical item has free shipping.
353 if ($isAllDigital || empty($physicalItems)) {
354 if ($returnType === 'items') {
355 foreach ($items as $key => $item) {
356 $items[$key]['shipping_charge'] = 0;
357 $items[$key]['itemwise_shipping_charge'] = 0;
358 }
359 return [
360 'items' => $items,
361 'shipping_amount' => 0
362 ];
363 }
364 return 0;
365 }
366
367 if (!$onceCalculated) {
368 $onceCalculated = true;
369 $productIds = array_unique(array_column($physicalItems, 'post_id'));
370 $products = Product::query()->whereIn('ID', $productIds)
371 ->with(['detail'])
372 ->get()
373 ->keyBy('ID');
374
375 $shippingClassIds = $products->pluck('detail.other_info.shipping_class')->filter(function ($item) {
376 return !empty($item);
377 })->toArray();
378
379 $shippingClasses = ShippingClass::query()->whereIn('id', $shippingClassIds)->get()->keyBy('id');
380 }
381
382 // Per-item annotation must run on every call, not gated behind $onceCalculated:
383 // $physicalItems is always re-derived fresh from the current $items argument above, so
384 // a cache-hit call still needs its own $items populated with shipping_charge and its
385 // own totals accumulated. Only the $products/$shippingClasses DB lookups above are
386 // safe to reuse across calls in the same request.
387 foreach ($physicalItems as $key => &$item) {
388 $totalQuantity += Arr::get($item, 'quantity');
389 $totalItemPrice += (Arr::get($item, 'quantity') * Arr::get($item, 'unit_price')) - Arr::get($item, 'discount_total');
390 $itemShippingCharge = 0;
391
392 $product = $products->get(Arr::get($item, 'post_id'));
393
394
395 if (isset($product->detail->other_info['shipping_class'])) {
396 // shipping_class is null or not defined
397 $shippingClass = $shippingClasses->get(
398 $product->detail->other_info['shipping_class']
399 );
400
401 if ($shippingClass) {
402 $perItem = $shippingClass->per_item;
403 $factor = empty($perItem) ? 1 : Arr::get($item, 'quantity');
404 if ($shippingClass->type === 'percentage') {
405 $itemShippingCharge = ($shippingClass->cost / 100) * Arr::get($item, 'unit_price') * $factor;
406 } else {
407 $itemShippingCharge = Helper::toCent($shippingClass->cost) * $factor;
408 }
409 }
410 }
411 $item['shipping_charge'] = $itemShippingCharge;
412 $totalShippingCharge += $itemShippingCharge;
413
414 $items[$key] = $item;
415 $maxShippingCharge = max($maxShippingCharge, $itemShippingCharge);
416 }
417 unset($item);
418
419 $settings = Arr::wrap($method->settings);
420 $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
421 $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
422
423 if ($configureRate === 'per_order') {
424 $shippingMethodAmount = $method->amount * 100;
425 } else if ($configureRate === 'per_price') {
426 $shippingMethodAmount = $totalItemPrice * ($method->amount / 100);
427 } else if ($configureRate === 'per_weight') {
428 // Sum (product weight + package weight) * quantity for all physical items
429 $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
430 $totalWeight = 0;
431
432 // Batch-load all variations to avoid N+1
433 $variationIds = array_filter(array_map(function ($item) {
434 return Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
435 }, $physicalItems));
436 $variationsMap = $variationIds ? ProductVariation::query()->whereIn('id', $variationIds)->get()->keyBy('id') : new \FluentCart\Framework\Support\Collection();
437
438 foreach ($physicalItems as $item) {
439 $variationId = Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
440 if ($variationId) {
441 $variation = $variationsMap->get($variationId);
442 if ($variation) {
443 $otherInfo = $variation->other_info ?: [];
444 $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
445 $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
446
447 // Convert product weight to store unit
448 $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
449
450 // Add package weight
451 $packageSlug = Arr::get($otherInfo, 'package_slug', '');
452 $package = Helper::getPackageBySlug($packageSlug);
453 $packageWeight = 0;
454 if ($package) {
455 $packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit);
456 $packageWeight = Helper::convertWeight(
457 floatval(Arr::get($package, 'weight', 0)),
458 $packageWeightUnit,
459 $storeWeightUnit
460 );
461 }
462
463 $totalWeight += ($convertedProductWeight + $packageWeight) * Arr::get($item, 'quantity', 1);
464 }
465 }
466 }
467
468 // Look up matching tier from weight_tiers
469 $weightTiers = Arr::get($settings, 'weight_tiers', []);
470 $shippingMethodAmount = 0;
471 foreach ($weightTiers as $tier) {
472 $min = floatval(Arr::get($tier, 'min', 0));
473 $max = floatval(Arr::get($tier, 'max', 0));
474 $cost = floatval(Arr::get($tier, 'cost', 0));
475
476 if ($totalWeight >= $min && ($max <= 0 || $totalWeight <= $max)) {
477 $shippingMethodAmount = Helper::toCent($cost);
478 break;
479 }
480 }
481 } else {
482 $shippingMethodAmount = $method->amount * $totalQuantity * 100;
483 }
484
485 if ($classAggregation === 'highest_class') {
486 $shippingMethodAmount += $maxShippingCharge;
487 } else {
488 $shippingMethodAmount += $totalShippingCharge;
489 }
490
491 $shippingMethodAmount = (int)round($shippingMethodAmount);
492
493 $remainingShippingMethodAmount = ($shippingMethodAmount - $totalShippingCharge);
494
495 // Distribution must run on every call (not gated behind a "once" flag): $physicalItems
496 // above is always re-derived fresh from the current $items argument regardless of the
497 // $onceCalculated cache, so a cached call still needs its own $items populated with
498 // itemwise_shipping_charge — a stale "already distributed" flag would leave a freshly
499 // passed-in items array with missing/zero shares even though the fingerprint matched.
500 $totalLineTotal = array_sum(array_column($physicalItems, 'line_total'));
501 $distributed = 0;
502 $itemCount = count($physicalItems);
503 $lastIndex = array_key_last($physicalItems);
504
505 foreach ($physicalItems as $key => $item) {
506 if ($key === $lastIndex) {
507 // Last item takes the exact remainder — per-item rounding must never
508 // change the total the customer is charged for shipping.
509 $share = (int) round($remainingShippingMethodAmount - $distributed);
510 } elseif ($totalLineTotal > 0) {
511 $share = (int) round(($item['line_total'] / $totalLineTotal) * $remainingShippingMethodAmount);
512 } else {
513 $share = (int) round($remainingShippingMethodAmount / $itemCount);
514 }
515 $items[$key]['itemwise_shipping_charge'] = $share;
516 $distributed += $share;
517 }
518
519 if ($isUsingCart) {
520 $cart = CartHelper::getCart();
521 $cart->cart_data = $items;
522 $cart->save();
523
524 do_action('fluent_cart/checkout/shipping_data_changed', [
525 'cart' => $cart
526 ]);
527 }
528
529 if ($returnType === 'items') {
530 return [
531 'items' => $items,
532 'shipping_amount' => $shippingMethodAmount
533 ];
534 }
535
536 return $shippingMethodAmount;
537 }
538
539 /**
540 * Calculate shipping charges using the profile-based approach.
541 * Groups cart items by shipping class, finds applicable methods per profile,
542 * falls back to General zones when no class-specific zones exist.
543 *
544 * @param int $shippingMethodId The selected shipping method ID
545 * @param array $cartItems Cart items
546 * @param string $country Country code
547 * @param string|null $state State code
548 * @param string $returnType 'amount' or 'items'
549 * @return int|array
550 */
551 public static function calculateShippingByProfile($shippingMethodId, $cartItems, $country, $state = null, $returnType = 'amount')
552 {
553 $cartCheckoutService = new CheckoutService($cartItems);
554 $isAllDigital = $cartCheckoutService->isAllDigital();
555 $physicalItems = $cartCheckoutService->physicalItems;
556
557 // Exclude only physical items marked for free shipping from profile-based charges.
558 static::excludeFreeShippingPhysicalItems($cartItems, $physicalItems);
559
560 // No shipping is charged for all-digital carts or when every physical item has free shipping.
561 if ($isAllDigital || empty($physicalItems)) {
562 if ($returnType === 'items') {
563 foreach ($cartItems as $key => $item) {
564 $cartItems[$key]['shipping_charge'] = 0;
565 $cartItems[$key]['itemwise_shipping_charge'] = 0;
566 }
567 return ['items' => $cartItems, 'shipping_amount' => 0];
568 }
569 return 0;
570 }
571
572 // Load products with details
573 $productIds = array_unique(array_column($physicalItems, 'post_id'));
574 $products = Product::query()->whereIn('ID', $productIds)
575 ->with(['detail'])
576 ->get()
577 ->keyBy('ID');
578
579 // Group physical items by shipping_class_id (null = General group)
580 $groups = [];
581 foreach ($physicalItems as $key => $item) {
582 $product = $products->get(Arr::get($item, 'post_id'));
583 $classId = null;
584 if ($product && isset($product->detail->other_info['shipping_class'])) {
585 $classId = $product->detail->other_info['shipping_class'] ?: null;
586 }
587 $groupKey = $classId ?: 'general';
588 if (!isset($groups[$groupKey])) {
589 $groups[$groupKey] = [
590 'class_id' => $classId,
591 'items' => [],
592 'keys' => []
593 ];
594 }
595 $groups[$groupKey]['items'][] = $item;
596 $groups[$groupKey]['keys'][] = $key;
597 }
598
599 // Load shipping classes for surcharge calculation
600 $classIds = array_filter(array_column($groups, 'class_id'));
601 $shippingClasses = !empty($classIds)
602 ? ShippingClass::query()->whereIn('id', $classIds)->get()->keyBy('id')
603 : new \FluentCart\Framework\Support\Collection();
604
605 $selectedMethod = ShippingMethod::find($shippingMethodId);
606 if (!$selectedMethod) {
607 if ($returnType === 'items') {
608 return ['items' => $cartItems, 'shipping_amount' => 0];
609 }
610 return 0;
611 }
612
613 // Early return for free_shipping — no base rate, no class surcharges
614 if ($selectedMethod->type === 'free_shipping') {
615 if ($returnType === 'items') {
616 foreach ($cartItems as $key => $item) {
617 $cartItems[$key]['shipping_charge'] = 0;
618 $cartItems[$key]['itemwise_shipping_charge'] = 0;
619 }
620 return ['items' => $cartItems, 'shipping_amount' => 0];
621 }
622 return 0;
623 }
624
625 $totalShippingAmount = 0;
626
627 // Preload all variations for weight calculation (avoids N+1 per group)
628 $allVariationIds = [];
629 foreach ($groups as $group) {
630 foreach ($group['items'] as $item) {
631 $vId = Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
632 if ($vId) $allVariationIds[] = $vId;
633 }
634 }
635 $allVariationIds = array_unique(array_filter($allVariationIds));
636 $allVariationsMap = !empty($allVariationIds)
637 ? ProductVariation::query()->whereIn('id', $allVariationIds)->get()->keyBy('id')
638 : new \FluentCart\Framework\Support\Collection();
639
640 // Compute cart-wide totals BEFORE the group loop (for per_order/per_price/per_weight base rate)
641 $cartTotalPrice = 0;
642 $cartTotalQuantity = 0;
643 foreach ($physicalItems as $item) {
644 $quantity = Arr::get($item, 'quantity', 1);
645 $cartTotalQuantity += $quantity;
646 $cartTotalPrice += ($quantity * Arr::get($item, 'unit_price', 0)) - Arr::get($item, 'discount_total', 0);
647 }
648
649 // Calculate the method base rate ONCE using cart-wide totals
650 $settings = Arr::wrap($selectedMethod->settings);
651 $configureRate = Arr::get($settings, 'configure_rate', 'per_order');
652 $classAggregation = Arr::get($settings, 'class_aggregation', 'sum_all');
653
654 if ($configureRate === 'per_order') {
655 $methodBaseRate = Helper::toCent($selectedMethod->amount);
656 } elseif ($configureRate === 'per_price') {
657 $methodBaseRate = $cartTotalPrice * ($selectedMethod->amount / 100);
658 } elseif ($configureRate === 'per_weight') {
659 $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
660 $totalWeight = 0;
661
662 foreach ($physicalItems as $item) {
663 $varId = Arr::get($item, 'object_id', Arr::get($item, 'variation_id'));
664 $variation = $varId ? $allVariationsMap->get($varId) : null;
665 if ($variation) {
666 $otherInfo = $variation->other_info ?: [];
667 $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
668 $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
669 $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
670
671 $packageSlug = Arr::get($otherInfo, 'package_slug', '');
672 $package = Helper::getPackageBySlug($packageSlug);
673 $packageWeight = 0;
674 if ($package) {
675 $packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit);
676 $packageWeight = Helper::convertWeight(
677 floatval(Arr::get($package, 'weight', 0)),
678 $packageWeightUnit,
679 $storeWeightUnit
680 );
681 }
682
683 $totalWeight += ($convertedProductWeight + $packageWeight) * Arr::get($item, 'quantity', 1);
684 }
685 }
686
687 $weightTiers = Arr::get($settings, 'weight_tiers', []);
688 $methodBaseRate = 0;
689 foreach ($weightTiers as $tier) {
690 $min = floatval(Arr::get($tier, 'min', 0));
691 $max = floatval(Arr::get($tier, 'max', 0));
692 if ($totalWeight >= $min && ($max <= 0 || $totalWeight <= $max)) {
693 $methodBaseRate = Helper::toCent(floatval(Arr::get($tier, 'cost', 0)));
694 break;
695 }
696 }
697 } else {
698 // per_item
699 $methodBaseRate = Helper::toCent($selectedMethod->amount) * $cartTotalQuantity;
700 }
701
702 // Accumulate class surcharges across all groups
703 $allGroupsClassCharge = 0;
704 $allGroupsMaxClassCharge = 0;
705
706 foreach ($groups as $groupKey => &$group) {
707 $classId = $group['class_id'];
708 $groupItems = $group['items'];
709
710 // Calculate class surcharges for this group
711 $groupTotalClassCharge = 0;
712 $groupMaxClassCharge = 0;
713
714 foreach ($groupItems as &$gItem) {
715 $quantity = Arr::get($gItem, 'quantity', 1);
716
717 // Calculate class surcharge per item
718 $itemClassCharge = 0;
719 if ($classId && $shippingClasses->has($classId)) {
720 $sc = $shippingClasses->get($classId);
721 $factor = $sc->per_item ? $quantity : 1;
722 if ($sc->type === 'percentage') {
723 $itemClassCharge = ($sc->cost / 100) * Arr::get($gItem, 'unit_price', 0) * $factor;
724 } else {
725 $itemClassCharge = Helper::toCent($sc->cost) * $factor;
726 }
727 }
728
729 $gItem['shipping_charge'] = $itemClassCharge;
730 $groupTotalClassCharge += $itemClassCharge;
731 $groupMaxClassCharge = max($groupMaxClassCharge, $itemClassCharge);
732 }
733 unset($gItem);
734
735 $allGroupsClassCharge += $groupTotalClassCharge;
736 $allGroupsMaxClassCharge = max($allGroupsMaxClassCharge, $groupMaxClassCharge);
737
738 $group['items'] = $groupItems;
739 $group['class_charge'] = $groupTotalClassCharge;
740 }
741 unset($group);
742
743 // Compute total: base rate (once) + class surcharges
744 if ($classAggregation === 'highest_class') {
745 $totalShippingAmount = $methodBaseRate + $allGroupsMaxClassCharge;
746 } else {
747 $totalShippingAmount = $methodBaseRate + $allGroupsClassCharge;
748 }
749
750 // Distribute the total shipping amount across all physical items proportionally
751 $totalLineTotal = 0;
752 foreach ($physicalItems as $item) {
753 $totalLineTotal += Arr::get($item, 'line_total', 0);
754 }
755
756 $methodOnlyAmount = $methodBaseRate;
757 $distributed = 0;
758 $itemCount = count($physicalItems);
759
760 // The last physical item overall (last item of the last group, in traversal order)
761 // absorbs the exact remainder — per-item rounding must never change the total
762 // the customer is charged for shipping.
763 $lastGroupKey = array_key_last($groups);
764 $lastItemIdx = ($lastGroupKey !== null && !empty($groups[$lastGroupKey]['items']))
765 ? array_key_last($groups[$lastGroupKey]['items'])
766 : null;
767
768 foreach ($groups as $groupKey => &$group) {
769 $groupItems = $group['items'];
770 foreach ($groupItems as $idx => &$gItem) {
771 if ($groupKey === $lastGroupKey && $idx === $lastItemIdx) {
772 $share = (int) round($methodOnlyAmount - $distributed);
773 } elseif ($totalLineTotal > 0) {
774 $share = (int) round((Arr::get($gItem, 'line_total', 0) / $totalLineTotal) * $methodOnlyAmount);
775 } else {
776 $share = $itemCount > 0 ? (int) round($methodOnlyAmount / $itemCount) : 0;
777 }
778 // itemwise_shipping_charge carries only the proportional base-rate share.
779 // The class surcharge stays exclusively in shipping_charge (set above) so it
780 // isn't taxed twice by TaxCalculator::getShippingTax(), which sums both fields.
781 $gItem['itemwise_shipping_charge'] = $share;
782 $distributed += $share;
783 }
784 unset($gItem);
785 $group['items'] = $groupItems;
786 $group['amount'] = $group['class_charge'];
787 }
788 unset($group);
789
790 // Merge group items back into cartItems
791 foreach ($groups as $group) {
792 foreach ($group['keys'] as $i => $key) {
793 if (isset($group['items'][$i])) {
794 $cartItems[$key]['shipping_charge'] = Arr::get($group['items'][$i], 'shipping_charge', 0);
795 $cartItems[$key]['itemwise_shipping_charge'] = Arr::get($group['items'][$i], 'itemwise_shipping_charge', 0);
796 }
797 }
798 }
799
800 if ($returnType === 'items') {
801 return [
802 'items' => $cartItems,
803 'shipping_amount' => $totalShippingAmount
804 ];
805 }
806
807 return $totalShippingAmount;
808 }
809
810 public static function resetShippingCharge()
811 {
812 $cart = CartHelper::getCart();
813 $items = $cart->cart_data;
814 foreach ($items as $key => $item) {
815 $items[$key]['shipping_charge'] = 0;
816 $items[$key]['itemwise_shipping_charge'] = 0;
817 }
818 $cart->cart_data = $items;
819
820 $cart->checkout_data = array_merge($cart->checkout_data, [
821 'shipping_data' => [
822 'shipping_method_id' => null,
823 'shipping_charge' => 0
824 ]
825 ]);
826
827 $cart->save();
828
829 do_action('fluent_cart/checkout/shipping_data_changed', [
830 'cart' => $cart
831 ]);
832 }
833
834 public static function generateCartFromVariation(ProductVariation $variation, $quantity = 1): Cart
835 {
836 $cart = new Cart();
837 $cart->cart_data = [
838 static::generateCartItemFromVariation($variation, $quantity)
839 ];
840
841 $cart = static::addCommonCartData($cart);
842 return $cart;
843 }
844
845 public static function addCommonCartData(Cart $cart)
846 {
847 if (is_user_logged_in()) {
848 $wpUser = wp_get_current_user();
849 $cart->user_id = get_current_user_id();
850 // The cart belongs to the account's linked customer, not to whichever record holds its email.
851 $customer = Customer::query()->where('user_id', $wpUser->ID)->orderBy('id', 'ASC')->first();
852 if ($customer) {
853 $cart->customer_id = $customer->id;
854 }
855 $cart->email = $wpUser->user_email;
856 $cart->first_name = $wpUser->first_name;
857 $cart->last_name = $wpUser->last_name;
858 $cart->ip_address = AddressHelper::getIpAddress();
859 $cart->user_agent = AddressHelper::getUserAgent();
860 }
861
862 return $cart;
863 }
864
865 public static function generateCartFromCustomVariation(array $variation, $quantity = 1): Cart
866 {
867 $cart = new Cart();
868 $cart->cart_data = [
869 static::generateCartItemCustomItem($variation, $quantity)
870 ];
871 return $cart;
872 }
873
874 /**
875 * Normalize custom item fields to standard cart variation format.
876 *
877 * NOTE:
878 * - Custom items may originate from external sources (filters, adjustments, migrations)
879 * - Some sources provide `id`, others only provide `item_id`
880 * - For cart consistency, `id` is required and will fall back to `item_id` when missing
881 * - This method intentionally mutates the provided object to normalize field names.
882 * The variation object is treated as a transient data structure and is not reused
883 * elsewhere after normalization.
884 *
885 * @param object $variation
886 * @return object
887 */
888 public static function normalizeCustomFields(object $variation): object
889 {
890 // Map custom fields to native fields only if they exist
891 $variation->id = $variation->id ?? $variation->item_id;
892 $variation->item_price = $variation->item_price
893 ?? $variation->unit_price
894 ?? $variation->price
895 ?? 0;
896 $variation->variation_title = $variation->title ?? ($variation->variation_title ?? '');
897
898
899 // Fallbacks
900 $variation->post_id = $variation->post_id ?? 0;
901 $variation->object_id = $variation->object_id ?? $variation->id;
902 $variation->unit_price = $variation->unit_price
903 ?? $variation->item_price
904 ?? $variation->price
905 ?? 0;
906
907 // Payment & fulfillment
908 $variation->payment_type = sanitize_text_field($variation->payment_type ?? 'onetime');
909 $variation->fulfillment_type = sanitize_text_field($variation->fulfillment_type ?? 'digital');
910
911 // Other info
912 if (!empty($variation->other_info) && is_array($variation->other_info)) {
913 $variation->other_info = $variation->other_info;
914 } else {
915 $variation->other_info = [];
916 }
917
918 // Add custom flags
919 $variation->other_info['is_custom'] = $variation->is_custom ?? false;
920 $variation->other_info['view_url'] = $variation->view_url ?? '';
921
922 return $variation;
923 }
924
925
926 /**
927 * @param ProductVariation $variation
928 * @param int|string $updatedQuantity
929 * @return bool
930 */
931 public static function shouldAddItemToCart(ProductVariation $variation, $updatedQuantity): bool
932 {
933 if ($variation->manage_stock == 0) {
934 return true;
935 }
936 return $updatedQuantity <= $variation->available;
937 }
938
939 public static function doingInstantCheckout()
940 {
941 $variationId = App::request()->get(Helper::INSTANT_CHECKOUT_URL_PARAM);
942 if (empty($variationId)) {
943 return false;
944 }
945 return $variationId;
946 }
947
948 /**
949 * @param \FluentCart\App\Models\Cart $cart
950 * @param array|\WP_Error $methods
951 * @param string|int|null $currentSelectedId
952 * @return string|int|null
953 */
954 public static function resolveAutoSelectShippingMethod($cart, $methods, $currentSelectedId)
955 {
956 if ($currentSelectedId || empty($methods) || is_wp_error($methods) || count($methods) !== 1) {
957 return $currentSelectedId;
958 }
959
960 $method = $methods[0];
961
962 $shouldAutoSelect = apply_filters('fluent_cart/shipping/auto_select_single_method', true, [
963 'cart' => $cart,
964 'method' => $method,
965 ]);
966
967 if (!$shouldAutoSelect) {
968 return $currentSelectedId;
969 }
970
971 $charge = static::calculateShippingMethodCharge($method, $cart->cart_data);
972
973 $cart->checkout_data = array_merge(
974 (array) $cart->checkout_data,
975 [
976 'shipping_data' => [
977 'shipping_method_id' => $method->id,
978 'shipping_charge' => is_array($charge) ? Arr::get($charge, 'shipping_amount', 0) : $charge,
979 ],
980 ]
981 );
982 $cart->save();
983
984 return $method->id;
985 }
986 }
987