PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / trunk
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler vtrunk
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 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Models / Cart.php

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

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