PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.4.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.4.0
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Models / Cart.php

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

1,328 lines 40.3 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 'inclusive' => !empty($fee['inclusive']),
854 'source' => sanitize_key($fee['source'] ?? 'custom'),
855 'meta' => (array) ($fee['meta'] ?? []),
856 ];
857
858 $checkoutData = $this->checkout_data ?? [];
859 $fees = (array) Arr::get($checkoutData, 'fees', []);
860
861 // Replace if same source:key exists, otherwise append
862 $compositeKey = $validatedFee['source'] . ':' . $validatedFee['key'];
863 $replaced = false;
864
865 foreach ($fees as $index => $existingFee) {
866 $existingComposite = Arr::get($existingFee, 'source', 'custom') . ':' . Arr::get($existingFee, 'key', '');
867 if ($existingComposite === $compositeKey) {
868 $fees[$index] = $validatedFee;
869 $replaced = true;
870 break;
871 }
872 }
873
874 if (!$replaced) {
875 $fees[] = $validatedFee;
876 }
877
878 $checkoutData['fees'] = array_values($fees);
879 $this->checkout_data = $checkoutData;
880 $this->clearFeeCache();
881 $this->save();
882
883 return true;
884 }
885
886 /**
887 * Remove a fee from the cart by key (and optionally source).
888 * Persists immediately to the database.
889 *
890 * @param string $key The fee key to remove
891 * @param string|null $source Optional source filter. If null, removes all fees with this key.
892 * @return bool Whether any fee was removed
893 */
894 public function removeFee(string $key, ?string $source = null): bool
895 {
896 $checkoutData = $this->checkout_data ?? [];
897 $fees = (array) Arr::get($checkoutData, 'fees', []);
898 $originalCount = count($fees);
899
900 $fees = array_filter($fees, function ($fee) use ($key, $source) {
901 if (Arr::get($fee, 'key') !== $key) {
902 return true; // keep — different key
903 }
904 if ($source !== null && Arr::get($fee, 'source', 'custom') !== $source) {
905 return true; // keep — different source
906 }
907 return false; // remove
908 });
909
910 if (count($fees) === $originalCount) {
911 return false; // nothing was removed
912 }
913
914 $checkoutData['fees'] = array_values($fees);
915 $this->checkout_data = $checkoutData;
916 $this->clearFeeCache();
917 $this->save();
918
919 return true;
920 }
921
922 /**
923 * Remove all fees from a specific source.
924 * Useful for addons to clear their fees before recalculating.
925 *
926 * @param string $source The source identifier
927 * @return void
928 */
929 public function removeFeesBySource(string $source): void
930 {
931 $checkoutData = $this->checkout_data ?? [];
932 $fees = (array) Arr::get($checkoutData, 'fees', []);
933
934 $fees = array_filter($fees, function ($fee) use ($source) {
935 return Arr::get($fee, 'source', 'custom') !== $source;
936 });
937
938 $checkoutData['fees'] = array_values($fees);
939 $this->checkout_data = $checkoutData;
940 $this->clearFeeCache();
941 $this->save();
942 }
943
944 /**
945 * Get the total of all fees in cents.
946 *
947 * @return int
948 */
949 public function getFeeTotal(): int
950 {
951 return array_reduce($this->getFees(), function ($carry, $fee) {
952 return $carry + (int) $fee['amount'];
953 }, 0);
954 }
955
956 /**
957 * Build cart-data-compatible items for fee items.
958 * Used by the tax module to calculate tax on taxable fees
959 * through the same pipeline as product items.
960 *
961 * @return array
962 */
963 public function getFeeCartItems(): array
964 {
965 $items = [];
966 foreach ($this->getFees() as $fee) {
967 $items[] = self::buildFeeCartItem($fee);
968 }
969 return $items;
970 }
971
972 /**
973 * Convert a validated fee array into a cart-data-compatible line item.
974 * Single source of truth for fee item structure — used by both
975 * getFeeCartItems() and TaxModule::calculateCartTax().
976 *
977 * @param array $fee Validated fee array
978 * @return array Cart-data-compatible item
979 */
980 public static function buildFeeCartItem(array $fee): array
981 {
982 $amount = (int) ($fee['amount'] ?? 0);
983
984 return [
985 'object_id' => 0,
986 'post_id' => 0,
987 'quantity' => 1,
988 'unit_price' => $amount,
989 'price' => $amount,
990 'subtotal' => $amount,
991 'line_total' => $amount,
992 'discount_total' => 0,
993 'coupon_discount' => 0,
994 'tax_amount' => 0,
995 'title' => $fee['label'] ?? '',
996 'post_title' => '',
997 'payment_type' => 'fee',
998 'is_fee' => true,
999 'fulfillment_type' => 'digital',
1000 'other_info' => [
1001 'payment_type' => 'fee',
1002 'fee_key' => $fee['key'] ?? '',
1003 'source' => $fee['source'] ?? 'custom',
1004 'taxable' => !empty($fee['taxable']),
1005 ],
1006 ];
1007 }
1008
1009 /**
1010 * Clear the per-request fee cache.
1011 * Call this after modifying fees or cart data.
1012 *
1013 * @return void
1014 */
1015 public function clearFeeCache(): void
1016 {
1017 $this->cachedFees = null;
1018 }
1019
1020 /**
1021 * Validate and deduplicate an array of fees.
1022 *
1023 * @param array $fees Raw fee items
1024 * @return array Validated fee items
1025 */
1026 private function validateFees(array $fees): array
1027 {
1028 $validFees = [];
1029
1030 foreach ($fees as $fee) {
1031 if (empty($fee['key']) || empty($fee['label']) || empty($fee['amount'])) {
1032 continue;
1033 }
1034
1035 $amount = (int) $fee['amount'];
1036 if ($amount <= 0) {
1037 continue;
1038 }
1039
1040 $source = sanitize_key($fee['source'] ?? 'custom');
1041 $compositeKey = $source . ':' . sanitize_key($fee['key']);
1042
1043 // Last wins — later entries (from filter) override earlier ones (stored)
1044 $validFees[$compositeKey] = [
1045 'key' => sanitize_key($fee['key']),
1046 'label' => sanitize_text_field($fee['label']),
1047 'amount' => $amount,
1048 'taxable' => !empty($fee['taxable']),
1049 'inclusive' => !empty($fee['inclusive']),
1050 'source' => $source,
1051 'meta' => (array) ($fee['meta'] ?? []),
1052 ];
1053 }
1054
1055 return array_values($validFees);
1056 }
1057
1058 public function getItemsSubtotal()
1059 {
1060 $checkoutItems = new CheckoutService($this->cart_data);
1061 $subscriptionItems = $checkoutItems->subscriptions;
1062 $onetimeItems = $checkoutItems->onetime;
1063
1064 $items = array_merge($onetimeItems, $subscriptionItems);
1065 return OrderService::getItemsAmountWithoutDiscount($items);
1066 }
1067
1068 private static bool $calculatingTotal = false;
1069
1070 public function getEstimatedTotal($extraAmount = 0)
1071 {
1072 // Recursion guard: if a hook calls getEstimatedTotal(), skip hooks to avoid infinite loop
1073 if (self::$calculatingTotal) {
1074 return $this->getEstimatedTotalRaw($extraAmount);
1075 }
1076
1077 self::$calculatingTotal = true;
1078
1079 do_action('fluent_cart/cart/before_totals_calculation', [
1080 'cart' => $this,
1081 ]);
1082
1083 $cartData = apply_filters('fluent_cart/cart/item_dynamic_discount', $this->cart_data, [
1084 'cart' => $this,
1085 ]);
1086
1087 $checkoutItems = new CheckoutService($cartData);
1088
1089 $subscriptionItems = $checkoutItems->subscriptions;
1090 $onetimeItems = $checkoutItems->onetime;
1091
1092 $items = array_merge($onetimeItems, $subscriptionItems);
1093
1094 $total = OrderService::getItemsAmountTotal($items, false, false, $extraAmount);
1095
1096 $shippingTotal = $this->getShippingTotal();
1097
1098 if ($shippingTotal) {
1099 $total += $shippingTotal;
1100 }
1101
1102 $feeTotal = $this->getFeeTotal();
1103 if ($feeTotal > 0) {
1104 $total += $feeTotal;
1105 }
1106
1107 if (Arr::get($this->checkout_data, 'custom_checkout') === 'yes' && !$shippingTotal) {
1108 $customShippingAmount = (int)Arr::get($this->checkout_data, 'custom_checkout_data.shipping_total', 0);
1109 // $customerDiscountAmount = (int)Arr::get($this->checkout_data, 'custom_checkout_data.discount_total', 0); // discount is already calculated in via getItemsAmountTotal
1110 // $total -= $customerDiscountAmount;
1111 $total += $customShippingAmount;
1112 }
1113
1114 if ($total < 0) {
1115 $total = 0;
1116 }
1117
1118 $finalTotal = apply_filters('fluent_cart/cart/estimated_total', $total, [
1119 'cart' => $this
1120 ]);
1121
1122 // Prorate credit and upgrade discount (plan upgrade) are post-tax adjustments: the
1123 // estimated_total filter has already added tax on the full price, now reduce the
1124 // payable total.
1125 $finalTotal = max(0, $finalTotal
1126 - (int) Arr::get($this->checkout_data ?? [], 'prorate_credit.amount', 0)
1127 - (int) Arr::get($this->checkout_data ?? [], 'upgrade_discount.amount', 0));
1128
1129 do_action('fluent_cart/cart/after_totals_calculation', [
1130 'cart' => $this,
1131 'total' => $finalTotal,
1132 ]);
1133
1134 self::$calculatingTotal = false;
1135
1136 return $finalTotal;
1137 }
1138
1139 /**
1140 * Raw total calculation without hooks (used for recursion guard).
1141 */
1142 private function getEstimatedTotalRaw($extraAmount = 0)
1143 {
1144 $checkoutItems = new CheckoutService($this->cart_data);
1145 $items = array_merge($checkoutItems->onetime, $checkoutItems->subscriptions);
1146 $total = OrderService::getItemsAmountTotal($items, false, false, $extraAmount);
1147
1148 $shippingTotal = (int)Arr::get($this->checkout_data ?? [], 'shipping_data.shipping_charge', 0);
1149 if ($shippingTotal) {
1150 $total += $shippingTotal;
1151 }
1152
1153 $feeTotal = $this->getFeeTotal();
1154 if ($feeTotal > 0) {
1155 $total += $feeTotal;
1156 }
1157
1158 $total -= (int) Arr::get($this->checkout_data ?? [], 'prorate_credit.amount', 0);
1159 $total -= (int) Arr::get($this->checkout_data ?? [], 'upgrade_discount.amount', 0);
1160
1161 return max(0, $total);
1162 }
1163
1164 /**
1165 * Get full cart context data for dynamic pricing and other addons.
1166 */
1167 public function getContextData(): array
1168 {
1169 $cartData = $this->cart_data ?? [];
1170 $customerId = $this->customer_id;
1171
1172 $context = [
1173 'cart_subtotal' => $this->getItemsSubtotal(),
1174 'cart_item_count' => count($cartData),
1175 'cart_total_quantity' => array_sum(array_column($cartData, 'quantity')),
1176 'shipping_method' => Arr::get($this->checkout_data, 'shipping_data.method_id'),
1177 'payment_method' => Arr::get($this->checkout_data, 'payment_method'),
1178 'customer_id' => $customerId,
1179 'order_type' => Arr::get($this->checkout_data, 'order_type', 'initial'),
1180 ];
1181
1182 return apply_filters('fluent_cart/cart/context_data', $context, [
1183 'cart' => $this,
1184 ]);
1185 }
1186
1187 public function getEstimatedRecurringTotal()
1188 {
1189 return array_reduce(
1190 $this->cart_data ?? [],
1191 function ($carry, $item) {
1192 if (Arr::get($item, 'other_info.payment_type') === 'subscription') {
1193 $subtotal = Arr::get($item, 'subtotal', 0);
1194 $discount = Arr::get($item, 'recurring_discounts.amount', 0);
1195 $carry += ($subtotal - $discount);
1196 }
1197 return $carry;
1198 },
1199 0
1200 );
1201 }
1202
1203 public function findExistingItemAndIndex($objectId, $extraArgs = [])
1204 {
1205 $cartData = array_values($this->cart_data);
1206
1207 if (!$cartData) {
1208 return null;
1209 }
1210
1211 foreach ($cartData as $index => $item) {
1212 if (Arr::get($item, 'object_id') == $objectId) {
1213 $match = true;
1214
1215 if ($extraArgs) {
1216 foreach ($extraArgs as $key => $value) {
1217 if (Arr::get($item, $key) != $value) {
1218 $match = false;
1219 break;
1220 }
1221 }
1222 }
1223
1224 if ($match) {
1225 return [$index, $item];
1226 }
1227 }
1228 }
1229
1230 return null;
1231 }
1232
1233 public function getShippingAddress()
1234 {
1235 $checkoutData = $this->checkout_data;
1236
1237 if (!is_array($checkoutData)) {
1238 return [];
1239 }
1240
1241 $formData = Arr::get($checkoutData, 'form_data', []);
1242 if ($this->isShipToDifferent()) {
1243 return [
1244 'full_name' => Arr::get($formData, 'shipping_full_name', ''),
1245 'company' => Arr::get($formData, 'shipping_company_name', ''),
1246 'address_1' => Arr::get($formData, 'shipping_address_1', ''),
1247 'address_2' => Arr::get($formData, 'shipping_address_2', ''),
1248 'city' => Arr::get($formData, 'shipping_city', ''),
1249 'state' => Arr::get($formData, 'shipping_state', ''),
1250 'postcode' => Arr::get($formData, 'shipping_postcode', ''),
1251 'country' => Arr::get($formData, 'shipping_country', ''),
1252 ];
1253 }
1254
1255 return $this->getBillingAddress();
1256 }
1257
1258 public function getBillingAddress()
1259 {
1260 $checkoutData = $this->checkout_data;
1261
1262 if (!is_array($checkoutData)) {
1263 return [];
1264 }
1265
1266 $formData = Arr::get($checkoutData, 'form_data', []);
1267
1268 return [
1269 'full_name' => Arr::get($formData, 'billing_full_name', ''),
1270 'company' => Arr::get($formData, 'billing_company', ''),
1271 'address_1' => Arr::get($formData, 'billing_address_1', ''),
1272 'address_2' => Arr::get($formData, 'billing_address_2', ''),
1273 'city' => Arr::get($formData, 'billing_city', ''),
1274 'state' => Arr::get($formData, 'billing_state', ''),
1275 'postcode' => Arr::get($formData, 'billing_postcode', ''),
1276 'country' => Arr::get($formData, 'billing_country', ''),
1277 ];
1278 }
1279
1280 public function isZeroPayment()
1281 {
1282 return !$this->getEstimatedTotal() && !$this->hasSubscription();
1283 }
1284
1285 public function isShipToDifferent()
1286 {
1287 return Arr::get($this->checkout_data, 'form_data.ship_to_different') === 'yes';
1288 }
1289
1290 // Unique hook handling
1291 protected function uniqueHooks($hooks)
1292 {
1293 return array_values(array_unique($hooks));
1294 }
1295
1296 public function addDraftCreatedActions($hooks)
1297 {
1298 return [
1299 '__after_draft_created_actions__' => $this->uniqueHooks($hooks)
1300 ];
1301 }
1302
1303 public function addSuccessActions($hooks)
1304 {
1305 return [
1306 '__on_success_actions__' => $this->uniqueHooks($hooks)
1307 ];
1308 }
1309
1310 public function addCartNotices($notices)
1311 {
1312 // Remove duplicates by notice ID
1313 $uniqueNotices = [];
1314 foreach ($notices as $notice) {
1315 $uniqueNotices[$notice['id']] = $notice;
1316 }
1317
1318 $uniqueNotices = array_values($uniqueNotices);
1319
1320 return [
1321 '__cart_notices' => $uniqueNotices
1322 ];
1323 }
1324
1325
1326
1327 }
1328