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

1,337 lines 40.7 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 /*
657 * Let addons resolve virtual (un-persisted) coupon codes into in-memory Coupon
658 * models so they appear in the summary discount line like any coupon. See
659 * DiscountService::applyCouponCodes() for the same filter.
660 */
661 $coupons = apply_filters('fluent_cart/coupon/resolve_coupons', $coupons, $this->coupons, [
662 'cart' => $this,
663 ]);
664
665 if ($coupons->isEmpty()) {
666 return [];
667 }
668
669 if ($coupons->count() === 1) {
670 $coupon = $coupons->first();
671 $discounts = array_sum(array_map(function ($item) {
672 return (int)Arr::get($item, 'coupon_discount', 0);
673 }, $this->cart_data ?? []));
674
675 $formattedTitle = $coupon->code;
676 if ($coupon->type === 'percentage') {
677 $formattedTitle .= ' (' . $coupon->amount . '%)';
678 }
679
680 $data = [
681 'id' => $coupon->id,
682 'code' => $coupon->code,
683 'type' => $coupon->discount_type,
684 'discount' => $discounts,
685 'formatted_discount' => CurrencySettings::getPriceHtml($discounts),
686 'actual_formatted_discount' => CurrencySettings::getPriceHtml($discounts),
687 'formatted_title' => $formattedTitle
688 ];
689
690 return [
691 $coupon->code => $data
692 ];
693 }
694
695
696 $formattedData = [];
697
698 foreach ($coupons as $coupon) {
699
700 $formattedTitle = $coupon->code;
701 if ($coupon->type === 'percentage') {
702 $formattedTitle .= ' (' . $coupon->amount . '%)';
703 }
704
705 $amount = Arr::get($this->checkout_data, '__per_coupon_discounts.' . $coupon->code, 0);
706
707 $formattedData[$coupon->code] = [
708 'id' => $coupon->id,
709 'code' => $coupon->code,
710 'type' => $coupon->discount_type,
711 'discount' => $amount,
712 'formatted_discount' => CurrencySettings::getPriceHtml($amount),
713 'actual_formatted_discount' => CurrencySettings::getPriceHtml($amount),
714 'formatted_title' => $formattedTitle
715 ];
716 }
717
718 return $formattedData;
719 }
720
721 public function hasSubscription()
722 {
723 if (!empty($this->cart_data)) {
724 foreach ($this->cart_data as $item) {
725 if (Arr::get($item, 'other_info.payment_type') === 'subscription') {
726 return true;
727 }
728 }
729 }
730
731 return false;
732 }
733
734 public function requireShipping()
735 {
736 if (!empty($this->cart_data)) {
737 foreach ($this->cart_data as $item) {
738 if (Arr::get($item, 'fulfillment_type') === 'physical') {
739 return true;
740 }
741 }
742 }
743
744 return false;
745 }
746
747 public function getShippingTotal()
748 {
749 if ($this->requireShipping()) {
750 $shippingTotal = (int)Arr::get($this->checkout_data ?? [], 'shipping_data.shipping_charge', 0);
751 return apply_filters('fluent_cart/cart/shipping_total', $shippingTotal, [
752 'cart' => $this,
753 ]);
754 }
755 return 0;
756 }
757
758 /**
759 * Get all fees for this cart.
760 * Reads persistent fees from checkout_data.fees and merges with
761 * dynamically computed fees from the fluent_cart/cart/fees filter.
762 * Uses per-request caching to avoid redundant DB reads and filter evaluations.
763 *
764 * @return array Validated fee items
765 */
766 public function getFees(): array
767 {
768 if ($this->cachedFees !== null) {
769 return $this->cachedFees;
770 }
771
772 // Recursion guard — if a filter callback calls getFees(), return stored fees only
773 if ($this->isCalculatingFees) {
774 return $this->getStoredFees();
775 }
776
777 $this->isCalculatingFees = true;
778
779 // Start with persistent (stored) fees
780 $storedFees = $this->getStoredFees();
781
782 // Custom payment: preserves the original order's charges.
783 // Reactivation: renewals should not pick up dynamic fees.
784 $isRenewal = Arr::get($this->checkout_data, 'renew_data.is_renewal') === 'yes';
785 if ($this->isLocked() || $isRenewal) {
786 $this->isCalculatingFees = false;
787 $this->cachedFees = $this->validateFees($storedFees);
788 return $this->cachedFees;
789 }
790
791 // Resolve payment method: prefer explicit key, fall back to form data
792 $paymentMethod = Arr::get($this->checkout_data, 'payment_method')
793 ?: Arr::get($this->checkout_data, 'form_data._fct_pay_method');
794
795 // Let addons add dynamic (computed) fees via filter
796 $allFees = apply_filters('fluent_cart/cart/fees', $storedFees, [
797 'cart' => $this,
798 'cart_items' => $this->cart_data ?? [],
799 'cart_subtotal' => $this->getItemsSubtotal(),
800 'shipping_total' => $this->getShippingTotal(),
801 'customer_id' => $this->customer_id,
802 'payment_method' => $paymentMethod,
803 'checkout_data' => $this->checkout_data,
804 ]);
805
806 if (!is_array($allFees)) {
807 $allFees = $storedFees;
808 }
809
810 // Validate and deduplicate (last wins — dynamic fees override stored)
811 $validFees = $this->validateFees($allFees);
812
813 $this->isCalculatingFees = false;
814 $this->cachedFees = $validFees;
815
816 return $validFees;
817 }
818
819 /**
820 * Get only the persistent (stored) fees from checkout_data.
821 *
822 * @return array
823 */
824 public function getStoredFees(): array
825 {
826 return (array) Arr::get($this->checkout_data ?? [], 'fees', []);
827 }
828
829 /**
830 * Add a fee to the cart. Persists immediately to the database.
831 * If a fee with the same source:key already exists, it will be updated.
832 *
833 * Usage:
834 * $cart->addFee([
835 * 'key' => 'processing_fee',
836 * 'label' => 'Processing Fee',
837 * 'amount' => 450, // cents, must be positive
838 * 'source' => 'dynamic-pricing',
839 * 'taxable' => false,
840 * 'meta' => ['rule_id' => 42],
841 * ]);
842 *
843 * @param array $fee Fee data with required keys: key, label, amount
844 * @return bool Whether the fee was added successfully
845 */
846 public function addFee(array $fee): bool
847 {
848 if (empty($fee['key']) || empty($fee['label']) || empty($fee['amount'])) {
849 return false;
850 }
851
852 $amount = (int) $fee['amount'];
853 if ($amount <= 0) {
854 return false;
855 }
856
857 $validatedFee = [
858 'key' => sanitize_key($fee['key']),
859 'label' => sanitize_text_field($fee['label']),
860 'amount' => $amount,
861 'taxable' => !empty($fee['taxable']),
862 'inclusive' => !empty($fee['inclusive']),
863 'source' => sanitize_key($fee['source'] ?? 'custom'),
864 'meta' => (array) ($fee['meta'] ?? []),
865 ];
866
867 $checkoutData = $this->checkout_data ?? [];
868 $fees = (array) Arr::get($checkoutData, 'fees', []);
869
870 // Replace if same source:key exists, otherwise append
871 $compositeKey = $validatedFee['source'] . ':' . $validatedFee['key'];
872 $replaced = false;
873
874 foreach ($fees as $index => $existingFee) {
875 $existingComposite = Arr::get($existingFee, 'source', 'custom') . ':' . Arr::get($existingFee, 'key', '');
876 if ($existingComposite === $compositeKey) {
877 $fees[$index] = $validatedFee;
878 $replaced = true;
879 break;
880 }
881 }
882
883 if (!$replaced) {
884 $fees[] = $validatedFee;
885 }
886
887 $checkoutData['fees'] = array_values($fees);
888 $this->checkout_data = $checkoutData;
889 $this->clearFeeCache();
890 $this->save();
891
892 return true;
893 }
894
895 /**
896 * Remove a fee from the cart by key (and optionally source).
897 * Persists immediately to the database.
898 *
899 * @param string $key The fee key to remove
900 * @param string|null $source Optional source filter. If null, removes all fees with this key.
901 * @return bool Whether any fee was removed
902 */
903 public function removeFee(string $key, ?string $source = null): bool
904 {
905 $checkoutData = $this->checkout_data ?? [];
906 $fees = (array) Arr::get($checkoutData, 'fees', []);
907 $originalCount = count($fees);
908
909 $fees = array_filter($fees, function ($fee) use ($key, $source) {
910 if (Arr::get($fee, 'key') !== $key) {
911 return true; // keep — different key
912 }
913 if ($source !== null && Arr::get($fee, 'source', 'custom') !== $source) {
914 return true; // keep — different source
915 }
916 return false; // remove
917 });
918
919 if (count($fees) === $originalCount) {
920 return false; // nothing was removed
921 }
922
923 $checkoutData['fees'] = array_values($fees);
924 $this->checkout_data = $checkoutData;
925 $this->clearFeeCache();
926 $this->save();
927
928 return true;
929 }
930
931 /**
932 * Remove all fees from a specific source.
933 * Useful for addons to clear their fees before recalculating.
934 *
935 * @param string $source The source identifier
936 * @return void
937 */
938 public function removeFeesBySource(string $source): void
939 {
940 $checkoutData = $this->checkout_data ?? [];
941 $fees = (array) Arr::get($checkoutData, 'fees', []);
942
943 $fees = array_filter($fees, function ($fee) use ($source) {
944 return Arr::get($fee, 'source', 'custom') !== $source;
945 });
946
947 $checkoutData['fees'] = array_values($fees);
948 $this->checkout_data = $checkoutData;
949 $this->clearFeeCache();
950 $this->save();
951 }
952
953 /**
954 * Get the total of all fees in cents.
955 *
956 * @return int
957 */
958 public function getFeeTotal(): int
959 {
960 return array_reduce($this->getFees(), function ($carry, $fee) {
961 return $carry + (int) $fee['amount'];
962 }, 0);
963 }
964
965 /**
966 * Build cart-data-compatible items for fee items.
967 * Used by the tax module to calculate tax on taxable fees
968 * through the same pipeline as product items.
969 *
970 * @return array
971 */
972 public function getFeeCartItems(): array
973 {
974 $items = [];
975 foreach ($this->getFees() as $fee) {
976 $items[] = self::buildFeeCartItem($fee);
977 }
978 return $items;
979 }
980
981 /**
982 * Convert a validated fee array into a cart-data-compatible line item.
983 * Single source of truth for fee item structure — used by both
984 * getFeeCartItems() and TaxModule::calculateCartTax().
985 *
986 * @param array $fee Validated fee array
987 * @return array Cart-data-compatible item
988 */
989 public static function buildFeeCartItem(array $fee): array
990 {
991 $amount = (int) ($fee['amount'] ?? 0);
992
993 return [
994 'object_id' => 0,
995 'post_id' => 0,
996 'quantity' => 1,
997 'unit_price' => $amount,
998 'price' => $amount,
999 'subtotal' => $amount,
1000 'line_total' => $amount,
1001 'discount_total' => 0,
1002 'coupon_discount' => 0,
1003 'tax_amount' => 0,
1004 'title' => $fee['label'] ?? '',
1005 'post_title' => '',
1006 'payment_type' => 'fee',
1007 'is_fee' => true,
1008 'fulfillment_type' => 'digital',
1009 'other_info' => [
1010 'payment_type' => 'fee',
1011 'fee_key' => $fee['key'] ?? '',
1012 'source' => $fee['source'] ?? 'custom',
1013 'taxable' => !empty($fee['taxable']),
1014 ],
1015 ];
1016 }
1017
1018 /**
1019 * Clear the per-request fee cache.
1020 * Call this after modifying fees or cart data.
1021 *
1022 * @return void
1023 */
1024 public function clearFeeCache(): void
1025 {
1026 $this->cachedFees = null;
1027 }
1028
1029 /**
1030 * Validate and deduplicate an array of fees.
1031 *
1032 * @param array $fees Raw fee items
1033 * @return array Validated fee items
1034 */
1035 private function validateFees(array $fees): array
1036 {
1037 $validFees = [];
1038
1039 foreach ($fees as $fee) {
1040 if (empty($fee['key']) || empty($fee['label']) || empty($fee['amount'])) {
1041 continue;
1042 }
1043
1044 $amount = (int) $fee['amount'];
1045 if ($amount <= 0) {
1046 continue;
1047 }
1048
1049 $source = sanitize_key($fee['source'] ?? 'custom');
1050 $compositeKey = $source . ':' . sanitize_key($fee['key']);
1051
1052 // Last wins — later entries (from filter) override earlier ones (stored)
1053 $validFees[$compositeKey] = [
1054 'key' => sanitize_key($fee['key']),
1055 'label' => sanitize_text_field($fee['label']),
1056 'amount' => $amount,
1057 'taxable' => !empty($fee['taxable']),
1058 'inclusive' => !empty($fee['inclusive']),
1059 'source' => $source,
1060 'meta' => (array) ($fee['meta'] ?? []),
1061 ];
1062 }
1063
1064 return array_values($validFees);
1065 }
1066
1067 public function getItemsSubtotal()
1068 {
1069 $checkoutItems = new CheckoutService($this->cart_data);
1070 $subscriptionItems = $checkoutItems->subscriptions;
1071 $onetimeItems = $checkoutItems->onetime;
1072
1073 $items = array_merge($onetimeItems, $subscriptionItems);
1074 return OrderService::getItemsAmountWithoutDiscount($items);
1075 }
1076
1077 private static bool $calculatingTotal = false;
1078
1079 public function getEstimatedTotal($extraAmount = 0)
1080 {
1081 // Recursion guard: if a hook calls getEstimatedTotal(), skip hooks to avoid infinite loop
1082 if (self::$calculatingTotal) {
1083 return $this->getEstimatedTotalRaw($extraAmount);
1084 }
1085
1086 self::$calculatingTotal = true;
1087
1088 do_action('fluent_cart/cart/before_totals_calculation', [
1089 'cart' => $this,
1090 ]);
1091
1092 $cartData = apply_filters('fluent_cart/cart/item_dynamic_discount', $this->cart_data, [
1093 'cart' => $this,
1094 ]);
1095
1096 $checkoutItems = new CheckoutService($cartData);
1097
1098 $subscriptionItems = $checkoutItems->subscriptions;
1099 $onetimeItems = $checkoutItems->onetime;
1100
1101 $items = array_merge($onetimeItems, $subscriptionItems);
1102
1103 $total = OrderService::getItemsAmountTotal($items, false, false, $extraAmount);
1104
1105 $shippingTotal = $this->getShippingTotal();
1106
1107 if ($shippingTotal) {
1108 $total += $shippingTotal;
1109 }
1110
1111 $feeTotal = $this->getFeeTotal();
1112 if ($feeTotal > 0) {
1113 $total += $feeTotal;
1114 }
1115
1116 if (Arr::get($this->checkout_data, 'custom_checkout') === 'yes' && !$shippingTotal) {
1117 $customShippingAmount = (int)Arr::get($this->checkout_data, 'custom_checkout_data.shipping_total', 0);
1118 // $customerDiscountAmount = (int)Arr::get($this->checkout_data, 'custom_checkout_data.discount_total', 0); // discount is already calculated in via getItemsAmountTotal
1119 // $total -= $customerDiscountAmount;
1120 $total += $customShippingAmount;
1121 }
1122
1123 if ($total < 0) {
1124 $total = 0;
1125 }
1126
1127 $finalTotal = apply_filters('fluent_cart/cart/estimated_total', $total, [
1128 'cart' => $this
1129 ]);
1130
1131 // Prorate credit and upgrade discount (plan upgrade) are post-tax adjustments: the
1132 // estimated_total filter has already added tax on the full price, now reduce the
1133 // payable total.
1134 $finalTotal = max(0, $finalTotal
1135 - (int) Arr::get($this->checkout_data ?? [], 'prorate_credit.amount', 0)
1136 - (int) Arr::get($this->checkout_data ?? [], 'upgrade_discount.amount', 0));
1137
1138 do_action('fluent_cart/cart/after_totals_calculation', [
1139 'cart' => $this,
1140 'total' => $finalTotal,
1141 ]);
1142
1143 self::$calculatingTotal = false;
1144
1145 return $finalTotal;
1146 }
1147
1148 /**
1149 * Raw total calculation without hooks (used for recursion guard).
1150 */
1151 private function getEstimatedTotalRaw($extraAmount = 0)
1152 {
1153 $checkoutItems = new CheckoutService($this->cart_data);
1154 $items = array_merge($checkoutItems->onetime, $checkoutItems->subscriptions);
1155 $total = OrderService::getItemsAmountTotal($items, false, false, $extraAmount);
1156
1157 $shippingTotal = (int)Arr::get($this->checkout_data ?? [], 'shipping_data.shipping_charge', 0);
1158 if ($shippingTotal) {
1159 $total += $shippingTotal;
1160 }
1161
1162 $feeTotal = $this->getFeeTotal();
1163 if ($feeTotal > 0) {
1164 $total += $feeTotal;
1165 }
1166
1167 $total -= (int) Arr::get($this->checkout_data ?? [], 'prorate_credit.amount', 0);
1168 $total -= (int) Arr::get($this->checkout_data ?? [], 'upgrade_discount.amount', 0);
1169
1170 return max(0, $total);
1171 }
1172
1173 /**
1174 * Get full cart context data for dynamic pricing and other addons.
1175 */
1176 public function getContextData(): array
1177 {
1178 $cartData = $this->cart_data ?? [];
1179 $customerId = $this->customer_id;
1180
1181 $context = [
1182 'cart_subtotal' => $this->getItemsSubtotal(),
1183 'cart_item_count' => count($cartData),
1184 'cart_total_quantity' => array_sum(array_column($cartData, 'quantity')),
1185 'shipping_method' => Arr::get($this->checkout_data, 'shipping_data.method_id'),
1186 'payment_method' => Arr::get($this->checkout_data, 'payment_method'),
1187 'customer_id' => $customerId,
1188 'order_type' => Arr::get($this->checkout_data, 'order_type', 'initial'),
1189 ];
1190
1191 return apply_filters('fluent_cart/cart/context_data', $context, [
1192 'cart' => $this,
1193 ]);
1194 }
1195
1196 public function getEstimatedRecurringTotal()
1197 {
1198 return array_reduce(
1199 $this->cart_data ?? [],
1200 function ($carry, $item) {
1201 if (Arr::get($item, 'other_info.payment_type') === 'subscription') {
1202 $subtotal = Arr::get($item, 'subtotal', 0);
1203 $discount = Arr::get($item, 'recurring_discounts.amount', 0);
1204 $carry += ($subtotal - $discount);
1205 }
1206 return $carry;
1207 },
1208 0
1209 );
1210 }
1211
1212 public function findExistingItemAndIndex($objectId, $extraArgs = [])
1213 {
1214 $cartData = array_values($this->cart_data);
1215
1216 if (!$cartData) {
1217 return null;
1218 }
1219
1220 foreach ($cartData as $index => $item) {
1221 if (Arr::get($item, 'object_id') == $objectId) {
1222 $match = true;
1223
1224 if ($extraArgs) {
1225 foreach ($extraArgs as $key => $value) {
1226 if (Arr::get($item, $key) != $value) {
1227 $match = false;
1228 break;
1229 }
1230 }
1231 }
1232
1233 if ($match) {
1234 return [$index, $item];
1235 }
1236 }
1237 }
1238
1239 return null;
1240 }
1241
1242 public function getShippingAddress()
1243 {
1244 $checkoutData = $this->checkout_data;
1245
1246 if (!is_array($checkoutData)) {
1247 return [];
1248 }
1249
1250 $formData = Arr::get($checkoutData, 'form_data', []);
1251 if ($this->isShipToDifferent()) {
1252 return [
1253 'full_name' => Arr::get($formData, 'shipping_full_name', ''),
1254 'company' => Arr::get($formData, 'shipping_company_name', ''),
1255 'address_1' => Arr::get($formData, 'shipping_address_1', ''),
1256 'address_2' => Arr::get($formData, 'shipping_address_2', ''),
1257 'city' => Arr::get($formData, 'shipping_city', ''),
1258 'state' => Arr::get($formData, 'shipping_state', ''),
1259 'postcode' => Arr::get($formData, 'shipping_postcode', ''),
1260 'country' => Arr::get($formData, 'shipping_country', ''),
1261 ];
1262 }
1263
1264 return $this->getBillingAddress();
1265 }
1266
1267 public function getBillingAddress()
1268 {
1269 $checkoutData = $this->checkout_data;
1270
1271 if (!is_array($checkoutData)) {
1272 return [];
1273 }
1274
1275 $formData = Arr::get($checkoutData, 'form_data', []);
1276
1277 return [
1278 'full_name' => Arr::get($formData, 'billing_full_name', ''),
1279 'company' => Arr::get($formData, 'billing_company', ''),
1280 'address_1' => Arr::get($formData, 'billing_address_1', ''),
1281 'address_2' => Arr::get($formData, 'billing_address_2', ''),
1282 'city' => Arr::get($formData, 'billing_city', ''),
1283 'state' => Arr::get($formData, 'billing_state', ''),
1284 'postcode' => Arr::get($formData, 'billing_postcode', ''),
1285 'country' => Arr::get($formData, 'billing_country', ''),
1286 ];
1287 }
1288
1289 public function isZeroPayment()
1290 {
1291 return !$this->getEstimatedTotal() && !$this->hasSubscription();
1292 }
1293
1294 public function isShipToDifferent()
1295 {
1296 return Arr::get($this->checkout_data, 'form_data.ship_to_different') === 'yes';
1297 }
1298
1299 // Unique hook handling
1300 protected function uniqueHooks($hooks)
1301 {
1302 return array_values(array_unique($hooks));
1303 }
1304
1305 public function addDraftCreatedActions($hooks)
1306 {
1307 return [
1308 '__after_draft_created_actions__' => $this->uniqueHooks($hooks)
1309 ];
1310 }
1311
1312 public function addSuccessActions($hooks)
1313 {
1314 return [
1315 '__on_success_actions__' => $this->uniqueHooks($hooks)
1316 ];
1317 }
1318
1319 public function addCartNotices($notices)
1320 {
1321 // Remove duplicates by notice ID
1322 $uniqueNotices = [];
1323 foreach ($notices as $notice) {
1324 $uniqueNotices[$notice['id']] = $notice;
1325 }
1326
1327 $uniqueNotices = array_values($uniqueNotices);
1328
1329 return [
1330 '__cart_notices' => $uniqueNotices
1331 ];
1332 }
1333
1334
1335
1336 }
1337