PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Models / Cart.php

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

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