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

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

1,310 lines 39.2 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
359 $item = CartHelper::generateCartItemFromVariation($variation, $quantity);
360 $otherInfoExtras = Arr::get($config, 'other_info', []);
361 if ($otherInfoExtras) {
362 $item['other_info'] = wp_parse_args($otherInfoExtras, $item['other_info']);
363 }
364
365 return $this->addItem($item, $replacingIndex);
366 }
367
368 public function addByCustom(array $variation, array $config = [])
369 {
370 $variation = CartHelper::normalizeCustomFields(
371 is_object($variation) ? $variation : (object) $variation
372 );
373
374 $variation = is_array($variation)
375 ? $variation
376 : (array) $variation;
377
378
379 if (!is_array($variation)) {
380 return new \WP_Error(
381 'invalid_custom_item',
382 __('Invalid custom item data.', 'fluent-cart')
383 );
384 }
385
386 $quantity = (int)Arr::get($config, 'quantity', 1);
387 $variationId = Arr::get($variation, 'id');
388
389 if ($quantity == 0) {
390 // that means we have to remove it
391 return $this->removeItem(
392 $variationId,
393 Arr::get($config, 'remove_args', []),
394 true
395 );
396 }
397
398 $requiredFields = [
399 'id',
400 'object_id',
401 'post_id',
402 'post_title',
403 'price',
404 'unit_price',
405 'payment_type'
406 ];
407
408 foreach ($requiredFields as $field) {
409 if (
410 !array_key_exists($field, $variation) ||
411 $variation[$field] === '' ||
412 $variation[$field] === null
413 ) {
414 // Missing required field → remove item
415 //Invalid custom items are never allowed to persist in cart state. Silent removal here is intentional to avoid breaking cart update/recalculation flows.
416
417 return $this->removeItem($variationId);
418 }
419 }
420
421 // Subscription items may exist in cart,
422 // but checkout must be initiated via direct checkout flow to ensure proper handling.
423 if (Arr::get($variation, 'payment_type', null) === 'subscription') {
424 return new \WP_Error('invalid_item', __('Subscription items must be purchased via direct checkout.', 'fluent-cart'));
425
426 }
427
428 // Find existing item in cart
429 $replacingIndex = null;
430 $existingItem = $this->findExistingItemAndIndex(
431 $variationId,
432 Arr::get($config, 'matched_args', [])
433 );
434
435 if ($existingItem) {
436 $replacingIndex = $existingItem[0];
437 }
438
439 if ($quantity <= 0) {
440 // remove the item if quantity is zero or negative after adjustment
441 return $this->removeItem($variationId);
442 }
443
444 $item = CartHelper::generateCartItemCustomItem($variation, $quantity);
445
446 return $this->addItem($item, $replacingIndex);
447 }
448
449 public function guessCustomer()
450 {
451 if ($this->customer_id) {
452 return Customer::find($this->customer_id);
453 }
454
455 if ($this->user_id) {
456 $customer = Customer::where('user_id', $this->user_id)->first();
457 if ($customer) {
458 return $customer;
459 }
460 }
461
462 if ($this->email) {
463 $customer = Customer::where('email', $this->email)->first();
464 if ($customer) {
465 return $customer;
466 }
467 }
468
469 return null;
470 }
471
472 public function reValidateCoupons()
473 {
474 if (!$this->coupons) {
475 return $this;
476 }
477
478 if ($this->isLocked()) {
479 return new \WP_Error('cart_locked', __('This cart is locked and cannot be modified.', 'fluent-cart'));
480 }
481
482 $prevDiscountTotal = array_sum(array_map(function ($item) {
483 return (int)Arr::get($item, 'discount_total', 0);
484 }, $this->cart_data ?? []));
485
486 $discountService = new \FluentCart\App\Services\Coupon\DiscountService($this);
487 $discountService->resetIndividualItemsDiscounts();
488 $discountService->applyCouponCodes($this->coupons);
489
490 $this->coupons = $discountService->getAppliedCoupons();
491 $this->cart_data = $discountService->getCartItems();
492
493 $checkoutData = $this->checkout_data;
494 if (!is_array($checkoutData)) {
495 $checkoutData = [];
496 }
497
498 $checkoutData['__per_coupon_discounts'] = $discountService->getPerCouponDiscounts();
499 $this->checkout_data = $checkoutData;
500
501 $this->save();
502
503 $newDiscountTotal = array_sum(array_map(function ($item) {
504 return (int)Arr::get($item, 'discount_total', 0);
505 }, $this->cart_data ?? []));
506
507 do_action('fluent_cart/checkout/cart_amount_updated', [
508 'cart' => $this
509 ]);
510
511 if ($newDiscountTotal != $prevDiscountTotal) {
512 do_action('fluent_cart/cart/cart_data_items_updated', [
513 'cart' => $this,
514 'scope' => 'discounts_recalculated',
515 'scope_data' => $this->coupons
516 ]);
517 }
518
519 return $this;
520
521 }
522
523 public function removeCoupon($removeCodes = [])
524 {
525 if (!is_array($removeCodes)) {
526 $removeCodes = [$removeCodes];
527 }
528
529 if ($this->isLocked()) {
530 return new \WP_Error('cart_locked', __('This cart is locked and cannot be modified.', 'fluent-cart'));
531 }
532
533 $this->coupons = array_filter($this->coupons, function ($code) use ($removeCodes) {
534 return !in_array($code, $removeCodes);
535 });
536
537 $discountService = new \FluentCart\App\Services\Coupon\DiscountService($this);
538
539 $discountService->resetIndividualItemsDiscounts();
540 $discountService->revalidateCoupons();
541
542 $this->cart_data = $discountService->getCartItems();
543 $this->coupons = $discountService->getAppliedCoupons();
544
545 $checkoutData = $this->checkout_data;
546 if (!is_array($checkoutData)) {
547 $checkoutData = [];
548 }
549
550 $checkoutData['__per_coupon_discounts'] = $discountService->getPerCouponDiscounts();
551 $this->checkout_data = $checkoutData;
552
553 $this->save();
554
555 do_action('fluent_cart/checkout/cart_amount_updated', [
556 'cart' => $this
557 ]);
558
559
560 do_action('fluent_cart/cart/cart_data_items_updated', [
561 'cart' => $this,
562 'scope' => 'remove_coupon',
563 'scope_data' => $removeCodes
564 ]);
565
566 return $this;
567 }
568
569 public function applyCoupon($codes = [])
570 {
571 if ($this->isLocked()) {
572 return new \WP_Error('cart_locked', __('This cart is locked and cannot be modified.', 'fluent-cart'));
573 }
574
575 $previousCartData = $this->cart_data;
576 $previousCoupons = $this->coupons;
577 $previousCheckoutData = $this->checkout_data;
578
579 $discountService = new \FluentCart\App\Services\Coupon\DiscountService($this);
580 $result = $discountService->applyCouponCodes($codes);
581 if (is_wp_error($result)) {
582 return $result;
583 }
584
585 $updatedCartItems = $discountService->getCartItems();
586
587 $this->coupons = $discountService->getAppliedCoupons();
588 $this->cart_data = $updatedCartItems;
589
590
591 $checkoutData = $this->checkout_data;
592 if (!is_array($checkoutData)) {
593 $checkoutData = [];
594 }
595
596 $checkoutData['__per_coupon_discounts'] = $discountService->getPerCouponDiscounts();
597 $this->checkout_data = $checkoutData;
598
599 $this->save();
600
601 do_action('fluent_cart/checkout/cart_amount_updated', [
602 'cart' => $this
603 ]);
604
605 do_action('fluent_cart/cart/cart_data_items_updated', [
606 'cart' => $this,
607 'scope' => 'apply_coupons',
608 'scope_data' => $codes
609 ]);
610
611 return $discountService->getResult();
612 }
613
614 protected function hasZeroRecurringAmount(array $cartItems)
615 {
616 foreach ($cartItems as $item) {
617 if (Arr::get($item, 'other_info.payment_type') !== 'subscription') {
618 continue;
619 }
620
621 $recurringDiscount = (int)Arr::get($item, 'recurring_discounts.amount', 0);
622
623 if ($recurringDiscount <= 0) {
624 continue;
625 }
626
627 $unitPrice = (int)Arr::get($item, 'unit_price', 0);
628 $remainingRecurring = $unitPrice - $recurringDiscount;
629
630 if ($remainingRecurring <= 0) {
631 return true;
632 }
633 }
634
635 return false;
636 }
637
638 public function getDiscountLines($revalidate = false)
639 {
640 if (!$this->coupons) {
641 return [];
642 }
643
644 if ($revalidate) {
645 $this->applyCoupon($this->coupons);
646 }
647
648 $coupons = Coupon::whereIn('code', $this->coupons)->get();
649
650 if ($coupons->isEmpty()) {
651 return [];
652 }
653
654 if ($coupons->count() === 1) {
655 $coupon = $coupons->first();
656 $discounts = array_sum(array_map(function ($item) {
657 return (int)Arr::get($item, 'coupon_discount', 0);
658 }, $this->cart_data ?? []));
659
660 $formattedTitle = $coupon->code;
661 if ($coupon->type === 'percentage') {
662 $formattedTitle .= ' (' . $coupon->amount . '%)';
663 }
664
665 $data = [
666 'id' => $coupon->id,
667 'code' => $coupon->code,
668 'type' => $coupon->discount_type,
669 'discount' => $discounts,
670 'formatted_discount' => CurrencySettings::getPriceHtml($discounts),
671 'actual_formatted_discount' => CurrencySettings::getPriceHtml($discounts),
672 'formatted_title' => $formattedTitle
673 ];
674
675 return [
676 $coupon->code => $data
677 ];
678 }
679
680
681 $formattedData = [];
682
683 foreach ($coupons as $coupon) {
684
685 $formattedTitle = $coupon->code;
686 if ($coupon->type === 'percentage') {
687 $formattedTitle .= ' (' . $coupon->amount . '%)';
688 }
689
690 $amount = Arr::get($this->checkout_data, '__per_coupon_discounts.' . $coupon->code, 0);
691
692 $formattedData[$coupon->code] = [
693 'id' => $coupon->id,
694 'code' => $coupon->code,
695 'type' => $coupon->discount_type,
696 'discount' => $amount,
697 'formatted_discount' => CurrencySettings::getPriceHtml($amount),
698 'actual_formatted_discount' => CurrencySettings::getPriceHtml($amount),
699 'formatted_title' => $formattedTitle
700 ];
701 }
702
703 return $formattedData;
704 }
705
706 public function hasSubscription()
707 {
708 if (!empty($this->cart_data)) {
709 foreach ($this->cart_data as $item) {
710 if (Arr::get($item, 'other_info.payment_type') === 'subscription') {
711 return true;
712 }
713 }
714 }
715
716 return false;
717 }
718
719 public function requireShipping()
720 {
721 if (!empty($this->cart_data)) {
722 foreach ($this->cart_data as $item) {
723 if (Arr::get($item, 'fulfillment_type') === 'physical') {
724 return true;
725 }
726 }
727 }
728
729 return false;
730 }
731
732 public function getShippingTotal()
733 {
734 if ($this->requireShipping()) {
735 $shippingTotal = (int)Arr::get($this->checkout_data ?? [], 'shipping_data.shipping_charge', 0);
736 return apply_filters('fluent_cart/cart/shipping_total', $shippingTotal, [
737 'cart' => $this,
738 ]);
739 }
740 return 0;
741 }
742
743 /**
744 * Get all fees for this cart.
745 * Reads persistent fees from checkout_data.fees and merges with
746 * dynamically computed fees from the fluent_cart/cart/fees filter.
747 * Uses per-request caching to avoid redundant DB reads and filter evaluations.
748 *
749 * @return array Validated fee items
750 */
751 public function getFees(): array
752 {
753 if ($this->cachedFees !== null) {
754 return $this->cachedFees;
755 }
756
757 // Recursion guard — if a filter callback calls getFees(), return stored fees only
758 if ($this->isCalculatingFees) {
759 return $this->getStoredFees();
760 }
761
762 $this->isCalculatingFees = true;
763
764 // Start with persistent (stored) fees
765 $storedFees = $this->getStoredFees();
766
767 // Custom payment: preserves the original order's charges.
768 // Reactivation: renewals should not pick up dynamic fees.
769 $isRenewal = Arr::get($this->checkout_data, 'renew_data.is_renewal') === 'yes';
770 if ($this->isLocked() || $isRenewal) {
771 $this->isCalculatingFees = false;
772 $this->cachedFees = $this->validateFees($storedFees);
773 return $this->cachedFees;
774 }
775
776 // Resolve payment method: prefer explicit key, fall back to form data
777 $paymentMethod = Arr::get($this->checkout_data, 'payment_method')
778 ?: Arr::get($this->checkout_data, 'form_data._fct_pay_method');
779
780 // Let addons add dynamic (computed) fees via filter
781 $allFees = apply_filters('fluent_cart/cart/fees', $storedFees, [
782 'cart' => $this,
783 'cart_items' => $this->cart_data ?? [],
784 'cart_subtotal' => $this->getItemsSubtotal(),
785 'shipping_total' => $this->getShippingTotal(),
786 'customer_id' => $this->customer_id,
787 'payment_method' => $paymentMethod,
788 'checkout_data' => $this->checkout_data,
789 ]);
790
791 if (!is_array($allFees)) {
792 $allFees = $storedFees;
793 }
794
795 // Validate and deduplicate (last wins — dynamic fees override stored)
796 $validFees = $this->validateFees($allFees);
797
798 $this->isCalculatingFees = false;
799 $this->cachedFees = $validFees;
800
801 return $validFees;
802 }
803
804 /**
805 * Get only the persistent (stored) fees from checkout_data.
806 *
807 * @return array
808 */
809 public function getStoredFees(): array
810 {
811 return (array) Arr::get($this->checkout_data ?? [], 'fees', []);
812 }
813
814 /**
815 * Add a fee to the cart. Persists immediately to the database.
816 * If a fee with the same source:key already exists, it will be updated.
817 *
818 * Usage:
819 * $cart->addFee([
820 * 'key' => 'processing_fee',
821 * 'label' => 'Processing Fee',
822 * 'amount' => 450, // cents, must be positive
823 * 'source' => 'dynamic-pricing',
824 * 'taxable' => false,
825 * 'meta' => ['rule_id' => 42],
826 * ]);
827 *
828 * @param array $fee Fee data with required keys: key, label, amount
829 * @return bool Whether the fee was added successfully
830 */
831 public function addFee(array $fee): bool
832 {
833 if (empty($fee['key']) || empty($fee['label']) || empty($fee['amount'])) {
834 return false;
835 }
836
837 $amount = (int) $fee['amount'];
838 if ($amount <= 0) {
839 return false;
840 }
841
842 $validatedFee = [
843 'key' => sanitize_key($fee['key']),
844 'label' => sanitize_text_field($fee['label']),
845 'amount' => $amount,
846 'taxable' => !empty($fee['taxable']),
847 'source' => sanitize_key($fee['source'] ?? 'custom'),
848 'meta' => (array) ($fee['meta'] ?? []),
849 ];
850
851 $checkoutData = $this->checkout_data ?? [];
852 $fees = (array) Arr::get($checkoutData, 'fees', []);
853
854 // Replace if same source:key exists, otherwise append
855 $compositeKey = $validatedFee['source'] . ':' . $validatedFee['key'];
856 $replaced = false;
857
858 foreach ($fees as $index => $existingFee) {
859 $existingComposite = Arr::get($existingFee, 'source', 'custom') . ':' . Arr::get($existingFee, 'key', '');
860 if ($existingComposite === $compositeKey) {
861 $fees[$index] = $validatedFee;
862 $replaced = true;
863 break;
864 }
865 }
866
867 if (!$replaced) {
868 $fees[] = $validatedFee;
869 }
870
871 $checkoutData['fees'] = array_values($fees);
872 $this->checkout_data = $checkoutData;
873 $this->clearFeeCache();
874 $this->save();
875
876 return true;
877 }
878
879 /**
880 * Remove a fee from the cart by key (and optionally source).
881 * Persists immediately to the database.
882 *
883 * @param string $key The fee key to remove
884 * @param string|null $source Optional source filter. If null, removes all fees with this key.
885 * @return bool Whether any fee was removed
886 */
887 public function removeFee(string $key, ?string $source = null): bool
888 {
889 $checkoutData = $this->checkout_data ?? [];
890 $fees = (array) Arr::get($checkoutData, 'fees', []);
891 $originalCount = count($fees);
892
893 $fees = array_filter($fees, function ($fee) use ($key, $source) {
894 if (Arr::get($fee, 'key') !== $key) {
895 return true; // keep — different key
896 }
897 if ($source !== null && Arr::get($fee, 'source', 'custom') !== $source) {
898 return true; // keep — different source
899 }
900 return false; // remove
901 });
902
903 if (count($fees) === $originalCount) {
904 return false; // nothing was removed
905 }
906
907 $checkoutData['fees'] = array_values($fees);
908 $this->checkout_data = $checkoutData;
909 $this->clearFeeCache();
910 $this->save();
911
912 return true;
913 }
914
915 /**
916 * Remove all fees from a specific source.
917 * Useful for addons to clear their fees before recalculating.
918 *
919 * @param string $source The source identifier
920 * @return void
921 */
922 public function removeFeesBySource(string $source): void
923 {
924 $checkoutData = $this->checkout_data ?? [];
925 $fees = (array) Arr::get($checkoutData, 'fees', []);
926
927 $fees = array_filter($fees, function ($fee) use ($source) {
928 return Arr::get($fee, 'source', 'custom') !== $source;
929 });
930
931 $checkoutData['fees'] = array_values($fees);
932 $this->checkout_data = $checkoutData;
933 $this->clearFeeCache();
934 $this->save();
935 }
936
937 /**
938 * Get the total of all fees in cents.
939 *
940 * @return int
941 */
942 public function getFeeTotal(): int
943 {
944 return array_reduce($this->getFees(), function ($carry, $fee) {
945 return $carry + (int) $fee['amount'];
946 }, 0);
947 }
948
949 /**
950 * Build cart-data-compatible items for fee items.
951 * Used by the tax module to calculate tax on taxable fees
952 * through the same pipeline as product items.
953 *
954 * @return array
955 */
956 public function getFeeCartItems(): array
957 {
958 $items = [];
959 foreach ($this->getFees() as $fee) {
960 $items[] = self::buildFeeCartItem($fee);
961 }
962 return $items;
963 }
964
965 /**
966 * Convert a validated fee array into a cart-data-compatible line item.
967 * Single source of truth for fee item structure — used by both
968 * getFeeCartItems() and TaxModule::calculateCartTax().
969 *
970 * @param array $fee Validated fee array
971 * @return array Cart-data-compatible item
972 */
973 public static function buildFeeCartItem(array $fee): array
974 {
975 $amount = (int) ($fee['amount'] ?? 0);
976
977 return [
978 'object_id' => 0,
979 'post_id' => 0,
980 'quantity' => 1,
981 'unit_price' => $amount,
982 'price' => $amount,
983 'subtotal' => $amount,
984 'line_total' => $amount,
985 'discount_total' => 0,
986 'coupon_discount' => 0,
987 'tax_amount' => 0,
988 'title' => $fee['label'] ?? '',
989 'post_title' => '',
990 'payment_type' => 'fee',
991 'is_fee' => true,
992 'fulfillment_type' => 'digital',
993 'other_info' => [
994 'payment_type' => 'fee',
995 'fee_key' => $fee['key'] ?? '',
996 'source' => $fee['source'] ?? 'custom',
997 'taxable' => !empty($fee['taxable']),
998 ],
999 ];
1000 }
1001
1002 /**
1003 * Clear the per-request fee cache.
1004 * Call this after modifying fees or cart data.
1005 *
1006 * @return void
1007 */
1008 public function clearFeeCache(): void
1009 {
1010 $this->cachedFees = null;
1011 }
1012
1013 /**
1014 * Validate and deduplicate an array of fees.
1015 *
1016 * @param array $fees Raw fee items
1017 * @return array Validated fee items
1018 */
1019 private function validateFees(array $fees): array
1020 {
1021 $validFees = [];
1022
1023 foreach ($fees as $fee) {
1024 if (empty($fee['key']) || empty($fee['label']) || empty($fee['amount'])) {
1025 continue;
1026 }
1027
1028 $amount = (int) $fee['amount'];
1029 if ($amount <= 0) {
1030 continue;
1031 }
1032
1033 $source = sanitize_key($fee['source'] ?? 'custom');
1034 $compositeKey = $source . ':' . sanitize_key($fee['key']);
1035
1036 // Last wins — later entries (from filter) override earlier ones (stored)
1037 $validFees[$compositeKey] = [
1038 'key' => sanitize_key($fee['key']),
1039 'label' => sanitize_text_field($fee['label']),
1040 'amount' => $amount,
1041 'taxable' => !empty($fee['taxable']),
1042 'source' => $source,
1043 'meta' => (array) ($fee['meta'] ?? []),
1044 ];
1045 }
1046
1047 return array_values($validFees);
1048 }
1049
1050 public function getItemsSubtotal()
1051 {
1052 $checkoutItems = new CheckoutService($this->cart_data);
1053 $subscriptionItems = $checkoutItems->subscriptions;
1054 $onetimeItems = $checkoutItems->onetime;
1055
1056 $items = array_merge($onetimeItems, $subscriptionItems);
1057 return OrderService::getItemsAmountWithoutDiscount($items);
1058 }
1059
1060 private static bool $calculatingTotal = false;
1061
1062 public function getEstimatedTotal($extraAmount = 0)
1063 {
1064 // Recursion guard: if a hook calls getEstimatedTotal(), skip hooks to avoid infinite loop
1065 if (self::$calculatingTotal) {
1066 return $this->getEstimatedTotalRaw($extraAmount);
1067 }
1068
1069 self::$calculatingTotal = true;
1070
1071 do_action('fluent_cart/cart/before_totals_calculation', [
1072 'cart' => $this,
1073 ]);
1074
1075 $cartData = apply_filters('fluent_cart/cart/item_dynamic_discount', $this->cart_data, [
1076 'cart' => $this,
1077 ]);
1078
1079 $checkoutItems = new CheckoutService($cartData);
1080
1081 $subscriptionItems = $checkoutItems->subscriptions;
1082 $onetimeItems = $checkoutItems->onetime;
1083
1084 $items = array_merge($onetimeItems, $subscriptionItems);
1085
1086 $total = OrderService::getItemsAmountTotal($items, false, false, $extraAmount);
1087
1088 $shippingTotal = $this->getShippingTotal();
1089
1090 if ($shippingTotal) {
1091 $total += $shippingTotal;
1092 }
1093
1094 $feeTotal = $this->getFeeTotal();
1095 if ($feeTotal > 0) {
1096 $total += $feeTotal;
1097 }
1098
1099 if (Arr::get($this->checkout_data, 'custom_checkout') === 'yes' && !$shippingTotal) {
1100 $customShippingAmount = (int)Arr::get($this->checkout_data, 'custom_checkout_data.shipping_total', 0);
1101 // $customerDiscountAmount = (int)Arr::get($this->checkout_data, 'custom_checkout_data.discount_total', 0); // discount is already calculated in via getItemsAmountTotal
1102 // $total -= $customerDiscountAmount;
1103 $total += $customShippingAmount;
1104 }
1105
1106 if ($total < 0) {
1107 $total = 0;
1108 }
1109
1110 $finalTotal = apply_filters('fluent_cart/cart/estimated_total', $total, [
1111 'cart' => $this
1112 ]);
1113
1114 do_action('fluent_cart/cart/after_totals_calculation', [
1115 'cart' => $this,
1116 'total' => $finalTotal,
1117 ]);
1118
1119 self::$calculatingTotal = false;
1120
1121 return $finalTotal;
1122 }
1123
1124 /**
1125 * Raw total calculation without hooks (used for recursion guard).
1126 */
1127 private function getEstimatedTotalRaw($extraAmount = 0)
1128 {
1129 $checkoutItems = new CheckoutService($this->cart_data);
1130 $items = array_merge($checkoutItems->onetime, $checkoutItems->subscriptions);
1131 $total = OrderService::getItemsAmountTotal($items, false, false, $extraAmount);
1132
1133 $shippingTotal = (int)Arr::get($this->checkout_data ?? [], 'shipping_data.shipping_charge', 0);
1134 if ($shippingTotal) {
1135 $total += $shippingTotal;
1136 }
1137
1138 $feeTotal = $this->getFeeTotal();
1139 if ($feeTotal > 0) {
1140 $total += $feeTotal;
1141 }
1142
1143 return max(0, $total);
1144 }
1145
1146 /**
1147 * Get full cart context data for dynamic pricing and other addons.
1148 */
1149 public function getContextData(): array
1150 {
1151 $cartData = $this->cart_data ?? [];
1152 $customerId = $this->customer_id;
1153
1154 $context = [
1155 'cart_subtotal' => $this->getItemsSubtotal(),
1156 'cart_item_count' => count($cartData),
1157 'cart_total_quantity' => array_sum(array_column($cartData, 'quantity')),
1158 'shipping_method' => Arr::get($this->checkout_data, 'shipping_data.method_id'),
1159 'payment_method' => Arr::get($this->checkout_data, 'payment_method'),
1160 'customer_id' => $customerId,
1161 'order_type' => Arr::get($this->checkout_data, 'order_type', 'initial'),
1162 ];
1163
1164 return apply_filters('fluent_cart/cart/context_data', $context, [
1165 'cart' => $this,
1166 ]);
1167 }
1168
1169 public function getEstimatedRecurringTotal()
1170 {
1171 return array_reduce(
1172 $this->cart_data ?? [],
1173 function ($carry, $item) {
1174 if (Arr::get($item, 'other_info.payment_type') === 'subscription') {
1175 $subtotal = Arr::get($item, 'subtotal', 0);
1176 $discount = Arr::get($item, 'recurring_discounts.amount', 0);
1177 $carry += ($subtotal - $discount);
1178 }
1179 return $carry;
1180 },
1181 0
1182 );
1183 }
1184
1185 public function findExistingItemAndIndex($objectId, $extraArgs = [])
1186 {
1187 $cartData = array_values($this->cart_data);
1188
1189 if (!$cartData) {
1190 return null;
1191 }
1192
1193 foreach ($cartData as $index => $item) {
1194 if (Arr::get($item, 'object_id') == $objectId) {
1195 $match = true;
1196
1197 if ($extraArgs) {
1198 foreach ($extraArgs as $key => $value) {
1199 if (Arr::get($item, $key) != $value) {
1200 $match = false;
1201 break;
1202 }
1203 }
1204 }
1205
1206 if ($match) {
1207 return [$index, $item];
1208 }
1209 }
1210 }
1211
1212 return null;
1213 }
1214
1215 public function getShippingAddress()
1216 {
1217 $checkoutData = $this->checkout_data;
1218
1219 if (!is_array($checkoutData)) {
1220 return [];
1221 }
1222
1223 $formData = Arr::get($checkoutData, 'form_data', []);
1224 if ($this->isShipToDifferent()) {
1225 return [
1226 'full_name' => Arr::get($formData, 'shipping_full_name', ''),
1227 'company' => Arr::get($formData, 'shipping_company_name', ''),
1228 'address_1' => Arr::get($formData, 'shipping_address_1', ''),
1229 'address_2' => Arr::get($formData, 'shipping_address_2', ''),
1230 'city' => Arr::get($formData, 'shipping_city', ''),
1231 'state' => Arr::get($formData, 'shipping_state', ''),
1232 'postcode' => Arr::get($formData, 'shipping_postcode', ''),
1233 'country' => Arr::get($formData, 'shipping_country', ''),
1234 ];
1235 }
1236
1237 return $this->getBillingAddress();
1238 }
1239
1240 public function getBillingAddress()
1241 {
1242 $checkoutData = $this->checkout_data;
1243
1244 if (!is_array($checkoutData)) {
1245 return [];
1246 }
1247
1248 $formData = Arr::get($checkoutData, 'form_data', []);
1249
1250 return [
1251 'full_name' => Arr::get($formData, 'billing_full_name', ''),
1252 'company' => Arr::get($formData, 'billing_company', ''),
1253 'address_1' => Arr::get($formData, 'billing_address_1', ''),
1254 'address_2' => Arr::get($formData, 'billing_address_2', ''),
1255 'city' => Arr::get($formData, 'billing_city', ''),
1256 'state' => Arr::get($formData, 'billing_state', ''),
1257 'postcode' => Arr::get($formData, 'billing_postcode', ''),
1258 'country' => Arr::get($formData, 'billing_country', ''),
1259 ];
1260 }
1261
1262 public function isZeroPayment()
1263 {
1264 return !$this->getEstimatedTotal() && !$this->hasSubscription();
1265 }
1266
1267 public function isShipToDifferent()
1268 {
1269 return Arr::get($this->checkout_data, 'form_data.ship_to_different') === 'yes';
1270 }
1271
1272 // Unique hook handling
1273 protected function uniqueHooks($hooks)
1274 {
1275 return array_values(array_unique($hooks));
1276 }
1277
1278 public function addDraftCreatedActions($hooks)
1279 {
1280 return [
1281 '__after_draft_created_actions__' => $this->uniqueHooks($hooks)
1282 ];
1283 }
1284
1285 public function addSuccessActions($hooks)
1286 {
1287 return [
1288 '__on_success_actions__' => $this->uniqueHooks($hooks)
1289 ];
1290 }
1291
1292 public function addCartNotices($notices)
1293 {
1294 // Remove duplicates by notice ID
1295 $uniqueNotices = [];
1296 foreach ($notices as $notice) {
1297 $uniqueNotices[$notice['id']] = $notice;
1298 }
1299
1300 $uniqueNotices = array_values($uniqueNotices);
1301
1302 return [
1303 '__cart_notices' => $uniqueNotices
1304 ];
1305 }
1306
1307
1308
1309 }
1310