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

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