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

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