PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 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 All 51 releases
fluent-cart / app / Models / Cart.php

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

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