PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.28
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.28
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 / Models / Cart.php

Cart.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.3.28, at app/Models/Cart.php

1,316 lines 39.6 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\Models;
4
5 use FluentCart\Api\Cookie\Cookie;
6 use FluentCart\Api\CurrencySettings;
7 use FluentCart\Api\Hasher\Hash;
8 use FluentCart\App\Helpers\CartHelper;
9 use FluentCart\App\Helpers\Helper;
10 use FluentCart\App\Models\Concerns\CanSearch;
11 use FluentCart\App\Services\CheckoutService;
12 use FluentCart\App\Services\OrderService;
13 use FluentCart\Framework\Database\Orm\Relations\BelongsTo;
14 use FluentCart\Framework\Database\Orm\SoftDeletes;
15 use FluentCart\Framework\Support\Arr;
16
17 /**
18 * Cart Session Model - DB Model for Carts
19 *
20 * Database Model
21 *
22 * @package FluentCart\App\Models
23 *
24 * @version 1.0.0
25 */
26 class Cart extends Model
27 {
28 use CanSearch;
29
30 protected $primaryKey = 'cart_hash';
31 public $incrementing = false;
32 protected $table = 'fct_carts';
33
34 protected $hidden = ['order_id', 'customer_id', 'user_id'];
35
36 /**
37 * Static cache for loaded cart data with bundle children
38 * Keyed by cart_hash (primary key)
39 *
40 * @var array
41 */
42 private static $cache = [];
43
44 /**
45 * Per-request cache for computed fees.
46 * @var array|null
47 */
48 private $cachedFees = null;
49
50 /**
51 * Recursion guard for getFees() to prevent infinite loops.
52 * @var bool
53 */
54 private $isCalculatingFees = false;
55
56 /**
57 * The attributes that are mass assignable.
58 *
59 * @var array
60 */
61 protected $fillable = [
62 'customer_id',
63 'user_id',
64 'order_id',
65 'cart_hash',
66 'checkout_data',
67 'cart_data',
68 'utm_data',
69 'coupons',
70 'first_name',
71 'last_name',
72 'email',
73 'stage',
74 'cart_group',
75 'user_agent',
76 'ip_address',
77 'completed_at',
78 'deleted_at',
79 ];
80
81 public static function boot()
82 {
83 parent::boot();
84 static::creating(function ($model) {
85 if (empty($model->cart_hash)) {
86 $model->cart_hash = md5('fct_global_cart_' . wp_generate_uuid4() . time());
87 }
88 });
89 }
90
91 public function setCheckoutDataAttribute($settings)
92 {
93 $this->attributes['checkout_data'] = json_encode(
94 Arr::wrap($settings)
95 );
96 }
97
98 public function getCheckoutDataAttribute($settings)
99 {
100 if (!$settings) {
101 return [];
102 }
103 $decoded = json_decode($settings, true);
104
105 if (!$decoded || !is_array($decoded)) {
106 return [];
107 }
108
109 return $decoded;
110 }
111
112 public function setCouponsAttribute($coupons)
113 {
114 if (!$coupons || !is_array($coupons)) {
115 $coupons = [];
116 }
117
118 $this->attributes['coupons'] = json_encode($coupons);
119 }
120
121 public function getCouponsAttribute($coupons)
122 {
123 if (!$coupons) {
124 return [];
125 }
126 $decoded = json_decode($coupons, true);
127
128 if (!$decoded || !is_array($decoded)) {
129 return [];
130 }
131
132 return $decoded;
133 }
134
135 public function setCartDataAttribute($settings)
136 {
137 $this->attributes['cart_data'] = json_encode(
138 Arr::wrap($settings)
139 );
140
141 $key = $this->getKey();
142 if ($key) {
143 unset(static::$cache[$key]);
144 }
145 }
146
147
148 public function getCartDataAttribute($data): array
149 {
150 if (!$data) {
151 return [];
152 }
153
154 $key = $this->getKey();
155
156 if ($key && isset(static::$cache[$key])) {
157 return static::$cache[$key];
158 }
159
160 $decoded = json_decode($data, true);
161
162 if (!$decoded || !is_array($decoded)) {
163 $result = [];
164 } else {
165 $result = Helper::loadBundleChild($decoded, ['*']);
166 }
167
168 if ($key) {
169 static::$cache[$key] = $result;
170 }
171
172 return $result;
173 }
174
175 public function setUtmDataAttribute($utmData)
176 {
177 $this->attributes['utm_data'] = json_encode(
178 Arr::wrap($utmData)
179 );
180 }
181
182 public function getUtmDataAttribute($utmData)
183 {
184 if (!$utmData) {
185 return [];
186 }
187 return json_decode($utmData, true);
188 }
189
190
191 /**
192 * One2One: Order belongs to one Customer
193 *
194 * @return BelongsTo
195 */
196 public function customer(): BelongsTo
197 {
198 return $this->belongsTo(Customer::class, 'customer_id', 'id');
199 }
200
201 /**
202 * One2One: Order belongs to one Customer
203 *
204 * @return BelongsTo
205 */
206 public function order(): BelongsTo
207 {
208 return $this->belongsTo(Order::class, 'order_id', 'id');
209 }
210
211 public function scopeStageNotCompleted($query)
212 {
213 return $query->where('stage', '!=', 'completed');
214 }
215
216 public function isLocked()
217 {
218 return Arr::get($this->checkout_data, 'is_locked') === 'yes' && $this->order_id;
219 }
220
221 public function addItem($item = [], $replacingIndex = null)
222 {
223 if ($this->isLocked()) {
224 return new \WP_Error('cart_locked', __('This cart is locked and cannot be modified.', 'fluent-cart'));
225 }
226 $cartData = $this->cart_data;
227 if ($replacingIndex !== null && isset($cartData[$replacingIndex])) {
228 $cartData[$replacingIndex] = $item;
229 } else {
230 $cartData[] = $item;
231 }
232
233 $this->cart_data = array_values($cartData);
234 $this->save();
235
236 $this->reValidateCoupons();
237
238 do_action('fluent_cart/cart/item_added', [
239 'cart' => $this,
240 'item' => $item
241 ]);
242
243 do_action('fluent_cart/cart/cart_data_items_updated', [
244 'cart' => $this,
245 'scope' => 'item_added',
246 'scope_data' => $item
247 ]);
248
249 return $this;
250 }
251
252 public function removeItem($variationId, $extraArgs = [], $triggerEvent = true)
253 {
254 if ($this->isLocked()) {
255 return new \WP_Error('cart_locked', __('This cart is locked and cannot be modified.', 'fluent-cart'));
256 }
257
258 $cartData = array_values($this->cart_data);
259
260 if (!$cartData) {
261 return $this;
262 }
263
264 $existingItemArr = $this->findExistingItemAndIndex($variationId, $extraArgs);
265 if (!$existingItemArr) {
266 return $this;
267 }
268
269 $targetIndex = $existingItemArr[0];
270 $removingItem = $existingItemArr[1];
271
272 unset($cartData[$targetIndex]);
273 $this->cart_data = array_values($cartData);
274 $this->save();
275
276 if ($triggerEvent) {
277 $this->reValidateCoupons();
278 do_action('fluent_cart/cart/item_removed', [
279 'cart' => $this,
280 'variation_id' => $variationId,
281 'extra_args' => $extraArgs,
282 'removed_item' => $removingItem
283 ]);
284 } else {
285 do_action('fluent_cart/checkout/cart_amount_updated', [
286 'cart' => $this
287 ]);
288 }
289
290 do_action('fluent_cart/cart/cart_data_items_updated', [
291 'cart' => $this,
292 'scope' => 'item_removed',
293 'scope_data' => $variationId
294 ]);
295
296 return $this;
297 }
298
299 public function addByVariation(ProductVariation $variation, $config = [])
300 {
301 $quantity = (int)Arr::get($config, 'quantity', 1);
302 $byInput = Arr::get($config, 'by_input', false);
303
304 if ($quantity == 0) {
305 // that means we have to remove it
306 return $this->removeItem($variation->id, Arr::get($config, 'remove_args', []), true);
307 }
308
309 $validate = Arr::get($config, 'will_validate', false);
310
311 $replacingIndex = null;
312
313 if (Arr::get($config, 'replace')) {
314 $this->removeItem($variation->id, Arr::get($config, 'remove_args', []), false);
315 } else {
316 $existingItem = $this->findExistingItemAndIndex($variation->id, Arr::get($config, 'matched_args', []));
317 if ($existingItem) {
318 $prevItem = $existingItem[1];
319 $replacingIndex = $existingItem[0];
320 if ($prevItem) { // it's promotional item. So we will just use the previous set price
321 if (!$byInput) {
322 $quantity += (int)Arr::get($prevItem, 'quantity', 1);
323 }
324 if (Arr::get($prevItem, 'other_info.promotion_id') || Arr::get($prevItem, 'other_info.is_price_locked') === 'yes') {
325 $unitPrice = Arr::get($prevItem, 'unit_price', 0);
326 if ($unitPrice) {
327 $variation->item_price = $unitPrice;
328 }
329
330 $providedOtherInfo = Arr::get($config, 'other_info', []);
331 $existingOtherInfo = Arr::get($prevItem, 'other_info', []);
332 $config['other_info'] = wp_parse_args($existingOtherInfo, $providedOtherInfo);
333 }
334 }
335 }
336 }
337
338 if ($quantity <= 0) {
339 // remove the item if quantity is zero or negative after adjustment
340 return $this->removeItem($variation->id);
341 }
342
343 if ($validate) {
344 $canPurchase = $variation->canPurchase($quantity);
345 $canPurchase = apply_filters('fluent_cart/cart/can_purchase', $canPurchase, [
346 'cart' => $this,
347 'variation' => $variation,
348 'quantity' => $quantity
349 ]);
350 if (is_wp_error($canPurchase)) {
351 return $canPurchase;
352 }
353
354 if ($this->isLocked()) {
355 return new \WP_Error('cart_locked', __('This cart is locked and cannot be modified.', 'fluent-cart'));
356 }
357
358 if ($replacingIndex === null && !empty($this->cart_data)) {
359 if ($variation->payment_type === 'subscription' || $this->hasSubscription()) {
360 return new \WP_Error('subscription_items_can_not_combined', __("Subscription items can't be combined with other products in the cart.", 'fluent-cart'));
361 }
362 }
363 }
364
365 $item = CartHelper::generateCartItemFromVariation($variation, $quantity);
366 $otherInfoExtras = Arr::get($config, 'other_info', []);
367 if ($otherInfoExtras) {
368 $item['other_info'] = wp_parse_args($otherInfoExtras, $item['other_info']);
369 }
370
371 return $this->addItem($item, $replacingIndex);
372 }
373
374 public function addByCustom(array $variation, array $config = [])
375 {
376 $variation = CartHelper::normalizeCustomFields(
377 is_object($variation) ? $variation : (object) $variation
378 );
379
380 $variation = is_array($variation)
381 ? $variation
382 : (array) $variation;
383
384
385 if (!is_array($variation)) {
386 return new \WP_Error(
387 'invalid_custom_item',
388 __('Invalid custom item data.', 'fluent-cart')
389 );
390 }
391
392 $quantity = (int)Arr::get($config, 'quantity', 1);
393 $variationId = Arr::get($variation, 'id');
394
395 if ($quantity == 0) {
396 // that means we have to remove it
397 return $this->removeItem(
398 $variationId,
399 Arr::get($config, 'remove_args', []),
400 true
401 );
402 }
403
404 $requiredFields = [
405 'id',
406 'object_id',
407 'post_id',
408 'post_title',
409 'price',
410 'unit_price',
411 'payment_type'
412 ];
413
414 foreach ($requiredFields as $field) {
415 if (
416 !array_key_exists($field, $variation) ||
417 $variation[$field] === '' ||
418 $variation[$field] === null
419 ) {
420 // Missing required field → remove item
421 //Invalid custom items are never allowed to persist in cart state. Silent removal here is intentional to avoid breaking cart update/recalculation flows.
422
423 return $this->removeItem($variationId);
424 }
425 }
426
427 // Subscription items may exist in cart,
428 // but checkout must be initiated via direct checkout flow to ensure proper handling.
429 if (Arr::get($variation, 'payment_type', null) === 'subscription') {
430 return new \WP_Error('invalid_item', __('Subscription items must be purchased via direct checkout.', 'fluent-cart'));
431
432 }
433
434 // Find existing item in cart
435 $replacingIndex = null;
436 $existingItem = $this->findExistingItemAndIndex(
437 $variationId,
438 Arr::get($config, 'matched_args', [])
439 );
440
441 if ($existingItem) {
442 $replacingIndex = $existingItem[0];
443 }
444
445 if ($quantity <= 0) {
446 // remove the item if quantity is zero or negative after adjustment
447 return $this->removeItem($variationId);
448 }
449
450 $item = CartHelper::generateCartItemCustomItem($variation, $quantity);
451
452 return $this->addItem($item, $replacingIndex);
453 }
454
455 public function guessCustomer()
456 {
457 if ($this->customer_id) {
458 return Customer::find($this->customer_id);
459 }
460
461 if ($this->user_id) {
462 $customer = Customer::where('user_id', $this->user_id)->first();
463 if ($customer) {
464 return $customer;
465 }
466 }
467
468 if ($this->email) {
469 $customer = Customer::where('email', $this->email)->first();
470 if ($customer) {
471 return $customer;
472 }
473 }
474
475 return null;
476 }
477
478 public function reValidateCoupons()
479 {
480 if (!$this->coupons) {
481 return $this;
482 }
483
484 if ($this->isLocked()) {
485 return new \WP_Error('cart_locked', __('This cart is locked and cannot be modified.', 'fluent-cart'));
486 }
487
488 $prevDiscountTotal = array_sum(array_map(function ($item) {
489 return (int)Arr::get($item, 'discount_total', 0);
490 }, $this->cart_data ?? []));
491
492 $discountService = new \FluentCart\App\Services\Coupon\DiscountService($this);
493 $discountService->resetIndividualItemsDiscounts();
494 $discountService->applyCouponCodes($this->coupons);
495
496 $this->coupons = $discountService->getAppliedCoupons();
497 $this->cart_data = $discountService->getCartItems();
498
499 $checkoutData = $this->checkout_data;
500 if (!is_array($checkoutData)) {
501 $checkoutData = [];
502 }
503
504 $checkoutData['__per_coupon_discounts'] = $discountService->getPerCouponDiscounts();
505 $this->checkout_data = $checkoutData;
506
507 $this->save();
508
509 $newDiscountTotal = array_sum(array_map(function ($item) {
510 return (int)Arr::get($item, 'discount_total', 0);
511 }, $this->cart_data ?? []));
512
513 do_action('fluent_cart/checkout/cart_amount_updated', [
514 'cart' => $this
515 ]);
516
517 if ($newDiscountTotal != $prevDiscountTotal) {
518 do_action('fluent_cart/cart/cart_data_items_updated', [
519 'cart' => $this,
520 'scope' => 'discounts_recalculated',
521 'scope_data' => $this->coupons
522 ]);
523 }
524
525 return $this;
526
527 }
528
529 public function removeCoupon($removeCodes = [])
530 {
531 if (!is_array($removeCodes)) {
532 $removeCodes = [$removeCodes];
533 }
534
535 if ($this->isLocked()) {
536 return new \WP_Error('cart_locked', __('This cart is locked and cannot be modified.', 'fluent-cart'));
537 }
538
539 $this->coupons = array_filter($this->coupons, function ($code) use ($removeCodes) {
540 return !in_array($code, $removeCodes);
541 });
542
543 $discountService = new \FluentCart\App\Services\Coupon\DiscountService($this);
544
545 $discountService->resetIndividualItemsDiscounts();
546 $discountService->revalidateCoupons();
547
548 $this->cart_data = $discountService->getCartItems();
549 $this->coupons = $discountService->getAppliedCoupons();
550
551 $checkoutData = $this->checkout_data;
552 if (!is_array($checkoutData)) {
553 $checkoutData = [];
554 }
555
556 $checkoutData['__per_coupon_discounts'] = $discountService->getPerCouponDiscounts();
557 $this->checkout_data = $checkoutData;
558
559 $this->save();
560
561 do_action('fluent_cart/checkout/cart_amount_updated', [
562 'cart' => $this
563 ]);
564
565
566 do_action('fluent_cart/cart/cart_data_items_updated', [
567 'cart' => $this,
568 'scope' => 'remove_coupon',
569 'scope_data' => $removeCodes
570 ]);
571
572 return $this;
573 }
574
575 public function applyCoupon($codes = [])
576 {
577 if ($this->isLocked()) {
578 return new \WP_Error('cart_locked', __('This cart is locked and cannot be modified.', 'fluent-cart'));
579 }
580
581 $previousCartData = $this->cart_data;
582 $previousCoupons = $this->coupons;
583 $previousCheckoutData = $this->checkout_data;
584
585 $discountService = new \FluentCart\App\Services\Coupon\DiscountService($this);
586 $result = $discountService->applyCouponCodes($codes);
587 if (is_wp_error($result)) {
588 return $result;
589 }
590
591 $updatedCartItems = $discountService->getCartItems();
592
593 $this->coupons = $discountService->getAppliedCoupons();
594 $this->cart_data = $updatedCartItems;
595
596
597 $checkoutData = $this->checkout_data;
598 if (!is_array($checkoutData)) {
599 $checkoutData = [];
600 }
601
602 $checkoutData['__per_coupon_discounts'] = $discountService->getPerCouponDiscounts();
603 $this->checkout_data = $checkoutData;
604
605 $this->save();
606
607 do_action('fluent_cart/checkout/cart_amount_updated', [
608 'cart' => $this
609 ]);
610
611 do_action('fluent_cart/cart/cart_data_items_updated', [
612 'cart' => $this,
613 'scope' => 'apply_coupons',
614 'scope_data' => $codes
615 ]);
616
617 return $discountService->getResult();
618 }
619
620 protected function hasZeroRecurringAmount(array $cartItems)
621 {
622 foreach ($cartItems as $item) {
623 if (Arr::get($item, 'other_info.payment_type') !== 'subscription') {
624 continue;
625 }
626
627 $recurringDiscount = (int)Arr::get($item, 'recurring_discounts.amount', 0);
628
629 if ($recurringDiscount <= 0) {
630 continue;
631 }
632
633 $unitPrice = (int)Arr::get($item, 'unit_price', 0);
634 $remainingRecurring = $unitPrice - $recurringDiscount;
635
636 if ($remainingRecurring <= 0) {
637 return true;
638 }
639 }
640
641 return false;
642 }
643
644 public function getDiscountLines($revalidate = false)
645 {
646 if (!$this->coupons) {
647 return [];
648 }
649
650 if ($revalidate) {
651 $this->applyCoupon($this->coupons);
652 }
653
654 $coupons = Coupon::whereIn('code', $this->coupons)->get();
655
656 if ($coupons->isEmpty()) {
657 return [];
658 }
659
660 if ($coupons->count() === 1) {
661 $coupon = $coupons->first();
662 $discounts = array_sum(array_map(function ($item) {
663 return (int)Arr::get($item, 'coupon_discount', 0);
664 }, $this->cart_data ?? []));
665
666 $formattedTitle = $coupon->code;
667 if ($coupon->type === 'percentage') {
668 $formattedTitle .= ' (' . $coupon->amount . '%)';
669 }
670
671 $data = [
672 'id' => $coupon->id,
673 'code' => $coupon->code,
674 'type' => $coupon->discount_type,
675 'discount' => $discounts,
676 'formatted_discount' => CurrencySettings::getPriceHtml($discounts),
677 'actual_formatted_discount' => CurrencySettings::getPriceHtml($discounts),
678 'formatted_title' => $formattedTitle
679 ];
680
681 return [
682 $coupon->code => $data
683 ];
684 }
685
686
687 $formattedData = [];
688
689 foreach ($coupons as $coupon) {
690
691 $formattedTitle = $coupon->code;
692 if ($coupon->type === 'percentage') {
693 $formattedTitle .= ' (' . $coupon->amount . '%)';
694 }
695
696 $amount = Arr::get($this->checkout_data, '__per_coupon_discounts.' . $coupon->code, 0);
697
698 $formattedData[$coupon->code] = [
699 'id' => $coupon->id,
700 'code' => $coupon->code,
701 'type' => $coupon->discount_type,
702 'discount' => $amount,
703 'formatted_discount' => CurrencySettings::getPriceHtml($amount),
704 'actual_formatted_discount' => CurrencySettings::getPriceHtml($amount),
705 'formatted_title' => $formattedTitle
706 ];
707 }
708
709 return $formattedData;
710 }
711
712 public function hasSubscription()
713 {
714 if (!empty($this->cart_data)) {
715 foreach ($this->cart_data as $item) {
716 if (Arr::get($item, 'other_info.payment_type') === 'subscription') {
717 return true;
718 }
719 }
720 }
721
722 return false;
723 }
724
725 public function requireShipping()
726 {
727 if (!empty($this->cart_data)) {
728 foreach ($this->cart_data as $item) {
729 if (Arr::get($item, 'fulfillment_type') === 'physical') {
730 return true;
731 }
732 }
733 }
734
735 return false;
736 }
737
738 public function getShippingTotal()
739 {
740 if ($this->requireShipping()) {
741 $shippingTotal = (int)Arr::get($this->checkout_data ?? [], 'shipping_data.shipping_charge', 0);
742 return apply_filters('fluent_cart/cart/shipping_total', $shippingTotal, [
743 'cart' => $this,
744 ]);
745 }
746 return 0;
747 }
748
749 /**
750 * Get all fees for this cart.
751 * Reads persistent fees from checkout_data.fees and merges with
752 * dynamically computed fees from the fluent_cart/cart/fees filter.
753 * Uses per-request caching to avoid redundant DB reads and filter evaluations.
754 *
755 * @return array Validated fee items
756 */
757 public function getFees(): array
758 {
759 if ($this->cachedFees !== null) {
760 return $this->cachedFees;
761 }
762
763 // Recursion guard — if a filter callback calls getFees(), return stored fees only
764 if ($this->isCalculatingFees) {
765 return $this->getStoredFees();
766 }
767
768 $this->isCalculatingFees = true;
769
770 // Start with persistent (stored) fees
771 $storedFees = $this->getStoredFees();
772
773 // Custom payment: preserves the original order's charges.
774 // Reactivation: renewals should not pick up dynamic fees.
775 $isRenewal = Arr::get($this->checkout_data, 'renew_data.is_renewal') === 'yes';
776 if ($this->isLocked() || $isRenewal) {
777 $this->isCalculatingFees = false;
778 $this->cachedFees = $this->validateFees($storedFees);
779 return $this->cachedFees;
780 }
781
782 // Resolve payment method: prefer explicit key, fall back to form data
783 $paymentMethod = Arr::get($this->checkout_data, 'payment_method')
784 ?: Arr::get($this->checkout_data, 'form_data._fct_pay_method');
785
786 // Let addons add dynamic (computed) fees via filter
787 $allFees = apply_filters('fluent_cart/cart/fees', $storedFees, [
788 'cart' => $this,
789 'cart_items' => $this->cart_data ?? [],
790 'cart_subtotal' => $this->getItemsSubtotal(),
791 'shipping_total' => $this->getShippingTotal(),
792 'customer_id' => $this->customer_id,
793 'payment_method' => $paymentMethod,
794 'checkout_data' => $this->checkout_data,
795 ]);
796
797 if (!is_array($allFees)) {
798 $allFees = $storedFees;
799 }
800
801 // Validate and deduplicate (last wins — dynamic fees override stored)
802 $validFees = $this->validateFees($allFees);
803
804 $this->isCalculatingFees = false;
805 $this->cachedFees = $validFees;
806
807 return $validFees;
808 }
809
810 /**
811 * Get only the persistent (stored) fees from checkout_data.
812 *
813 * @return array
814 */
815 public function getStoredFees(): array
816 {
817 return (array) Arr::get($this->checkout_data ?? [], 'fees', []);
818 }
819
820 /**
821 * Add a fee to the cart. Persists immediately to the database.
822 * If a fee with the same source:key already exists, it will be updated.
823 *
824 * Usage:
825 * $cart->addFee([
826 * 'key' => 'processing_fee',
827 * 'label' => 'Processing Fee',
828 * 'amount' => 450, // cents, must be positive
829 * 'source' => 'dynamic-pricing',
830 * 'taxable' => false,
831 * 'meta' => ['rule_id' => 42],
832 * ]);
833 *
834 * @param array $fee Fee data with required keys: key, label, amount
835 * @return bool Whether the fee was added successfully
836 */
837 public function addFee(array $fee): bool
838 {
839 if (empty($fee['key']) || empty($fee['label']) || empty($fee['amount'])) {
840 return false;
841 }
842
843 $amount = (int) $fee['amount'];
844 if ($amount <= 0) {
845 return false;
846 }
847
848 $validatedFee = [
849 'key' => sanitize_key($fee['key']),
850 'label' => sanitize_text_field($fee['label']),
851 'amount' => $amount,
852 'taxable' => !empty($fee['taxable']),
853 'source' => sanitize_key($fee['source'] ?? 'custom'),
854 'meta' => (array) ($fee['meta'] ?? []),
855 ];
856
857 $checkoutData = $this->checkout_data ?? [];
858 $fees = (array) Arr::get($checkoutData, 'fees', []);
859
860 // Replace if same source:key exists, otherwise append
861 $compositeKey = $validatedFee['source'] . ':' . $validatedFee['key'];
862 $replaced = false;
863
864 foreach ($fees as $index => $existingFee) {
865 $existingComposite = Arr::get($existingFee, 'source', 'custom') . ':' . Arr::get($existingFee, 'key', '');
866 if ($existingComposite === $compositeKey) {
867 $fees[$index] = $validatedFee;
868 $replaced = true;
869 break;
870 }
871 }
872
873 if (!$replaced) {
874 $fees[] = $validatedFee;
875 }
876
877 $checkoutData['fees'] = array_values($fees);
878 $this->checkout_data = $checkoutData;
879 $this->clearFeeCache();
880 $this->save();
881
882 return true;
883 }
884
885 /**
886 * Remove a fee from the cart by key (and optionally source).
887 * Persists immediately to the database.
888 *
889 * @param string $key The fee key to remove
890 * @param string|null $source Optional source filter. If null, removes all fees with this key.
891 * @return bool Whether any fee was removed
892 */
893 public function removeFee(string $key, ?string $source = null): bool
894 {
895 $checkoutData = $this->checkout_data ?? [];
896 $fees = (array) Arr::get($checkoutData, 'fees', []);
897 $originalCount = count($fees);
898
899 $fees = array_filter($fees, function ($fee) use ($key, $source) {
900 if (Arr::get($fee, 'key') !== $key) {
901 return true; // keep — different key
902 }
903 if ($source !== null && Arr::get($fee, 'source', 'custom') !== $source) {
904 return true; // keep — different source
905 }
906 return false; // remove
907 });
908
909 if (count($fees) === $originalCount) {
910 return false; // nothing was removed
911 }
912
913 $checkoutData['fees'] = array_values($fees);
914 $this->checkout_data = $checkoutData;
915 $this->clearFeeCache();
916 $this->save();
917
918 return true;
919 }
920
921 /**
922 * Remove all fees from a specific source.
923 * Useful for addons to clear their fees before recalculating.
924 *
925 * @param string $source The source identifier
926 * @return void
927 */
928 public function removeFeesBySource(string $source): void
929 {
930 $checkoutData = $this->checkout_data ?? [];
931 $fees = (array) Arr::get($checkoutData, 'fees', []);
932
933 $fees = array_filter($fees, function ($fee) use ($source) {
934 return Arr::get($fee, 'source', 'custom') !== $source;
935 });
936
937 $checkoutData['fees'] = array_values($fees);
938 $this->checkout_data = $checkoutData;
939 $this->clearFeeCache();
940 $this->save();
941 }
942
943 /**
944 * Get the total of all fees in cents.
945 *
946 * @return int
947 */
948 public function getFeeTotal(): int
949 {
950 return array_reduce($this->getFees(), function ($carry, $fee) {
951 return $carry + (int) $fee['amount'];
952 }, 0);
953 }
954
955 /**
956 * Build cart-data-compatible items for fee items.
957 * Used by the tax module to calculate tax on taxable fees
958 * through the same pipeline as product items.
959 *
960 * @return array
961 */
962 public function getFeeCartItems(): array
963 {
964 $items = [];
965 foreach ($this->getFees() as $fee) {
966 $items[] = self::buildFeeCartItem($fee);
967 }
968 return $items;
969 }
970
971 /**
972 * Convert a validated fee array into a cart-data-compatible line item.
973 * Single source of truth for fee item structure — used by both
974 * getFeeCartItems() and TaxModule::calculateCartTax().
975 *
976 * @param array $fee Validated fee array
977 * @return array Cart-data-compatible item
978 */
979 public static function buildFeeCartItem(array $fee): array
980 {
981 $amount = (int) ($fee['amount'] ?? 0);
982
983 return [
984 'object_id' => 0,
985 'post_id' => 0,
986 'quantity' => 1,
987 'unit_price' => $amount,
988 'price' => $amount,
989 'subtotal' => $amount,
990 'line_total' => $amount,
991 'discount_total' => 0,
992 'coupon_discount' => 0,
993 'tax_amount' => 0,
994 'title' => $fee['label'] ?? '',
995 'post_title' => '',
996 'payment_type' => 'fee',
997 'is_fee' => true,
998 'fulfillment_type' => 'digital',
999 'other_info' => [
1000 'payment_type' => 'fee',
1001 'fee_key' => $fee['key'] ?? '',
1002 'source' => $fee['source'] ?? 'custom',
1003 'taxable' => !empty($fee['taxable']),
1004 ],
1005 ];
1006 }
1007
1008 /**
1009 * Clear the per-request fee cache.
1010 * Call this after modifying fees or cart data.
1011 *
1012 * @return void
1013 */
1014 public function clearFeeCache(): void
1015 {
1016 $this->cachedFees = null;
1017 }
1018
1019 /**
1020 * Validate and deduplicate an array of fees.
1021 *
1022 * @param array $fees Raw fee items
1023 * @return array Validated fee items
1024 */
1025 private function validateFees(array $fees): array
1026 {
1027 $validFees = [];
1028
1029 foreach ($fees as $fee) {
1030 if (empty($fee['key']) || empty($fee['label']) || empty($fee['amount'])) {
1031 continue;
1032 }
1033
1034 $amount = (int) $fee['amount'];
1035 if ($amount <= 0) {
1036 continue;
1037 }
1038
1039 $source = sanitize_key($fee['source'] ?? 'custom');
1040 $compositeKey = $source . ':' . sanitize_key($fee['key']);
1041
1042 // Last wins — later entries (from filter) override earlier ones (stored)
1043 $validFees[$compositeKey] = [
1044 'key' => sanitize_key($fee['key']),
1045 'label' => sanitize_text_field($fee['label']),
1046 'amount' => $amount,
1047 'taxable' => !empty($fee['taxable']),
1048 'source' => $source,
1049 'meta' => (array) ($fee['meta'] ?? []),
1050 ];
1051 }
1052
1053 return array_values($validFees);
1054 }
1055
1056 public function getItemsSubtotal()
1057 {
1058 $checkoutItems = new CheckoutService($this->cart_data);
1059 $subscriptionItems = $checkoutItems->subscriptions;
1060 $onetimeItems = $checkoutItems->onetime;
1061
1062 $items = array_merge($onetimeItems, $subscriptionItems);
1063 return OrderService::getItemsAmountWithoutDiscount($items);
1064 }
1065
1066 private static bool $calculatingTotal = false;
1067
1068 public function getEstimatedTotal($extraAmount = 0)
1069 {
1070 // Recursion guard: if a hook calls getEstimatedTotal(), skip hooks to avoid infinite loop
1071 if (self::$calculatingTotal) {
1072 return $this->getEstimatedTotalRaw($extraAmount);
1073 }
1074
1075 self::$calculatingTotal = true;
1076
1077 do_action('fluent_cart/cart/before_totals_calculation', [
1078 'cart' => $this,
1079 ]);
1080
1081 $cartData = apply_filters('fluent_cart/cart/item_dynamic_discount', $this->cart_data, [
1082 'cart' => $this,
1083 ]);
1084
1085 $checkoutItems = new CheckoutService($cartData);
1086
1087 $subscriptionItems = $checkoutItems->subscriptions;
1088 $onetimeItems = $checkoutItems->onetime;
1089
1090 $items = array_merge($onetimeItems, $subscriptionItems);
1091
1092 $total = OrderService::getItemsAmountTotal($items, false, false, $extraAmount);
1093
1094 $shippingTotal = $this->getShippingTotal();
1095
1096 if ($shippingTotal) {
1097 $total += $shippingTotal;
1098 }
1099
1100 $feeTotal = $this->getFeeTotal();
1101 if ($feeTotal > 0) {
1102 $total += $feeTotal;
1103 }
1104
1105 if (Arr::get($this->checkout_data, 'custom_checkout') === 'yes' && !$shippingTotal) {
1106 $customShippingAmount = (int)Arr::get($this->checkout_data, 'custom_checkout_data.shipping_total', 0);
1107 // $customerDiscountAmount = (int)Arr::get($this->checkout_data, 'custom_checkout_data.discount_total', 0); // discount is already calculated in via getItemsAmountTotal
1108 // $total -= $customerDiscountAmount;
1109 $total += $customShippingAmount;
1110 }
1111
1112 if ($total < 0) {
1113 $total = 0;
1114 }
1115
1116 $finalTotal = apply_filters('fluent_cart/cart/estimated_total', $total, [
1117 'cart' => $this
1118 ]);
1119
1120 do_action('fluent_cart/cart/after_totals_calculation', [
1121 'cart' => $this,
1122 'total' => $finalTotal,
1123 ]);
1124
1125 self::$calculatingTotal = false;
1126
1127 return $finalTotal;
1128 }
1129
1130 /**
1131 * Raw total calculation without hooks (used for recursion guard).
1132 */
1133 private function getEstimatedTotalRaw($extraAmount = 0)
1134 {
1135 $checkoutItems = new CheckoutService($this->cart_data);
1136 $items = array_merge($checkoutItems->onetime, $checkoutItems->subscriptions);
1137 $total = OrderService::getItemsAmountTotal($items, false, false, $extraAmount);
1138
1139 $shippingTotal = (int)Arr::get($this->checkout_data ?? [], 'shipping_data.shipping_charge', 0);
1140 if ($shippingTotal) {
1141 $total += $shippingTotal;
1142 }
1143
1144 $feeTotal = $this->getFeeTotal();
1145 if ($feeTotal > 0) {
1146 $total += $feeTotal;
1147 }
1148
1149 return max(0, $total);
1150 }
1151
1152 /**
1153 * Get full cart context data for dynamic pricing and other addons.
1154 */
1155 public function getContextData(): array
1156 {
1157 $cartData = $this->cart_data ?? [];
1158 $customerId = $this->customer_id;
1159
1160 $context = [
1161 'cart_subtotal' => $this->getItemsSubtotal(),
1162 'cart_item_count' => count($cartData),
1163 'cart_total_quantity' => array_sum(array_column($cartData, 'quantity')),
1164 'shipping_method' => Arr::get($this->checkout_data, 'shipping_data.method_id'),
1165 'payment_method' => Arr::get($this->checkout_data, 'payment_method'),
1166 'customer_id' => $customerId,
1167 'order_type' => Arr::get($this->checkout_data, 'order_type', 'initial'),
1168 ];
1169
1170 return apply_filters('fluent_cart/cart/context_data', $context, [
1171 'cart' => $this,
1172 ]);
1173 }
1174
1175 public function getEstimatedRecurringTotal()
1176 {
1177 return array_reduce(
1178 $this->cart_data ?? [],
1179 function ($carry, $item) {
1180 if (Arr::get($item, 'other_info.payment_type') === 'subscription') {
1181 $subtotal = Arr::get($item, 'subtotal', 0);
1182 $discount = Arr::get($item, 'recurring_discounts.amount', 0);
1183 $carry += ($subtotal - $discount);
1184 }
1185 return $carry;
1186 },
1187 0
1188 );
1189 }
1190
1191 public function findExistingItemAndIndex($objectId, $extraArgs = [])
1192 {
1193 $cartData = array_values($this->cart_data);
1194
1195 if (!$cartData) {
1196 return null;
1197 }
1198
1199 foreach ($cartData as $index => $item) {
1200 if (Arr::get($item, 'object_id') == $objectId) {
1201 $match = true;
1202
1203 if ($extraArgs) {
1204 foreach ($extraArgs as $key => $value) {
1205 if (Arr::get($item, $key) != $value) {
1206 $match = false;
1207 break;
1208 }
1209 }
1210 }
1211
1212 if ($match) {
1213 return [$index, $item];
1214 }
1215 }
1216 }
1217
1218 return null;
1219 }
1220
1221 public function getShippingAddress()
1222 {
1223 $checkoutData = $this->checkout_data;
1224
1225 if (!is_array($checkoutData)) {
1226 return [];
1227 }
1228
1229 $formData = Arr::get($checkoutData, 'form_data', []);
1230 if ($this->isShipToDifferent()) {
1231 return [
1232 'full_name' => Arr::get($formData, 'shipping_full_name', ''),
1233 'company' => Arr::get($formData, 'shipping_company_name', ''),
1234 'address_1' => Arr::get($formData, 'shipping_address_1', ''),
1235 'address_2' => Arr::get($formData, 'shipping_address_2', ''),
1236 'city' => Arr::get($formData, 'shipping_city', ''),
1237 'state' => Arr::get($formData, 'shipping_state', ''),
1238 'postcode' => Arr::get($formData, 'shipping_postcode', ''),
1239 'country' => Arr::get($formData, 'shipping_country', ''),
1240 ];
1241 }
1242
1243 return $this->getBillingAddress();
1244 }
1245
1246 public function getBillingAddress()
1247 {
1248 $checkoutData = $this->checkout_data;
1249
1250 if (!is_array($checkoutData)) {
1251 return [];
1252 }
1253
1254 $formData = Arr::get($checkoutData, 'form_data', []);
1255
1256 return [
1257 'full_name' => Arr::get($formData, 'billing_full_name', ''),
1258 'company' => Arr::get($formData, 'billing_company', ''),
1259 'address_1' => Arr::get($formData, 'billing_address_1', ''),
1260 'address_2' => Arr::get($formData, 'billing_address_2', ''),
1261 'city' => Arr::get($formData, 'billing_city', ''),
1262 'state' => Arr::get($formData, 'billing_state', ''),
1263 'postcode' => Arr::get($formData, 'billing_postcode', ''),
1264 'country' => Arr::get($formData, 'billing_country', ''),
1265 ];
1266 }
1267
1268 public function isZeroPayment()
1269 {
1270 return !$this->getEstimatedTotal() && !$this->hasSubscription();
1271 }
1272
1273 public function isShipToDifferent()
1274 {
1275 return Arr::get($this->checkout_data, 'form_data.ship_to_different') === 'yes';
1276 }
1277
1278 // Unique hook handling
1279 protected function uniqueHooks($hooks)
1280 {
1281 return array_values(array_unique($hooks));
1282 }
1283
1284 public function addDraftCreatedActions($hooks)
1285 {
1286 return [
1287 '__after_draft_created_actions__' => $this->uniqueHooks($hooks)
1288 ];
1289 }
1290
1291 public function addSuccessActions($hooks)
1292 {
1293 return [
1294 '__on_success_actions__' => $this->uniqueHooks($hooks)
1295 ];
1296 }
1297
1298 public function addCartNotices($notices)
1299 {
1300 // Remove duplicates by notice ID
1301 $uniqueNotices = [];
1302 foreach ($notices as $notice) {
1303 $uniqueNotices[$notice['id']] = $notice;
1304 }
1305
1306 $uniqueNotices = array_values($uniqueNotices);
1307
1308 return [
1309 '__cart_notices' => $uniqueNotices
1310 ];
1311 }
1312
1313
1314
1315 }
1316