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

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