PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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
← All changes | app/Models/Cart.php +135 -8 1.4.1 → 1.6.5 View file →
@@ -4,8 +4,9 @@
4 4
5 5 use FluentCart\Api\Cookie\Cookie;
6 6 use FluentCart\Api\CurrencySettings;
7 7 use FluentCart\Api\Hasher\Hash;
8 +use FluentCart\App\Helpers\AttributeHelper;
8 9 use FluentCart\App\Helpers\CartHelper;
9 10 use FluentCart\App\Helpers\Helper;
10 11 use FluentCart\App\Models\Concerns\CanSearch;
11 12 use FluentCart\App\Services\CheckoutService;
@@ -133,12 +134,68 @@
133 134 }
134 135
135 136 public function setCartDataAttribute($settings)
136 137 {
137 - $this->attributes['cart_data'] = json_encode(
138 - Arr::wrap($settings)
139 - );
138 + $items = Arr::wrap($settings);
140 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 +
141 198 $key = $this->getKey();
142 199 if ($key) {
143 200 unset(static::$cache[$key]);
144 201 }
@@ -151,19 +208,20 @@
151 208 return [];
152 209 }
153 210
154 211 $key = $this->getKey();
155 -
212 +
156 213 if ($key && isset(static::$cache[$key])) {
157 214 return static::$cache[$key];
158 215 }
159 216
160 217 $decoded = json_decode($data, true);
161 -
218 +
162 219 if (!$decoded || !is_array($decoded)) {
163 220 $result = [];
164 221 } else {
165 222 $result = Helper::loadBundleChild($decoded, ['*']);
223 + $result = static::appendVariationDisplayTitle($result);
166 224 }
167 225
168 226 if ($key) {
169 227 static::$cache[$key] = $result;
@@ -171,8 +229,34 @@
171 229
172 230 return $result;
173 231 }
174 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 +
175 259 public function setUtmDataAttribute($utmData)
176 260 {
177 261 $this->attributes['utm_data'] = json_encode(
178 262 Arr::wrap($utmData)
@@ -217,8 +301,38 @@
217 301 {
218 302 return Arr::get($this->checkout_data, 'is_locked') === 'yes' && $this->order_id;
219 303 }
220 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 +
221 335 public function addItem($item = [], $replacingIndex = null)
222 336 {
223 337 if ($this->isLocked()) {
224 338 return new \WP_Error('cart_locked', __('This cart is locked and cannot be modified.', 'fluent-cart'));
@@ -305,8 +419,12 @@
305 419 // that means we have to remove it
306 420 return $this->removeItem($variation->id, Arr::get($config, 'remove_args', []), true);
307 421 }
308 422
423 + if (!$variation->product) {
424 + return new \WP_Error('product_not_found', __('This product is no longer available.', 'fluent-cart'));
425 + }
426 +
309 427 $validate = Arr::get($config, 'will_validate', false);
310 428
311 429 $replacingIndex = null;
312 430
@@ -423,10 +541,10 @@
423 541 return $this->removeItem($variationId);
424 542 }
425 543 }
426 544
427 - // Subscription items may exist in cart,
428 - // but checkout must be initiated via direct checkout flow to ensure proper handling.
545 + // Subscription items may exist in cart,
546 + // but checkout must be initiated via direct checkout flow to ensure proper handling.
429 547 if (Arr::get($variation, 'payment_type', null) === 'subscription') {
430 548 return new \WP_Error('invalid_item', __('Subscription items must be purchased via direct checkout.', 'fluent-cart'));
431 549
432 550 }
@@ -431,9 +549,9 @@
431 549
432 550 }
433 551
434 552 // Find existing item in cart
435 - $replacingIndex = null;
553 + $replacingIndex = null;
436 554 $existingItem = $this->findExistingItemAndIndex(
437 555 $variationId,
438 556 Arr::get($config, 'matched_args', [])
439 557 );
@@ -651,8 +769,17 @@
651 769 $this->applyCoupon($this->coupons);
652 770 }
653 771
654 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 + ]);
655 782
656 783 if ($coupons->isEmpty()) {
657 784 return [];
658 785 }