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
← All changes | app/Models/Cart.php +114 -24 1.5.1 → 1.7.1 View file →
@@ -136,16 +136,61 @@
136 136 public function setCartDataAttribute($settings)
137 137 {
138 138 $items = Arr::wrap($settings);
139 139
140 - // variation_display_title is a presentation-only value derived on read
141 - // (getCartDataAttribute). Strip it before persisting so mutation paths
142 - // that round-trip cart_data never bake stale denormalized text into the
143 - // stored JSON.
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 +
144 164 foreach ($items as &$item) {
145 - if (is_array($item)) {
146 - unset($item['variation_display_title']);
165 + if (!is_array($item)) {
166 + continue;
147 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 + }
148 193 }
149 194 unset($item);
150 195
151 196 $this->attributes['cart_data'] = json_encode($items);
@@ -163,9 +208,9 @@
163 208 return [];
164 209 }
165 210
166 211 $key = $this->getKey();
167 -
212 +
168 213 if ($key && isset(static::$cache[$key])) {
169 214 return static::$cache[$key];
170 215 }
171 216
@@ -197,22 +242,17 @@
197 242 */
198 243 protected static function appendVariationDisplayTitle(array $items): array
199 244 {
200 245 return array_map(function ($item) {
201 - if (!is_array($item)) {
202 - return $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 + );
203 253 }
204 254
205 - $itemAttributes = Arr::get($item, 'other_info.item_attributes', []);
206 -
207 - $variationDisplayTitle = (is_array($itemAttributes) && $itemAttributes)
208 - ? AttributeHelper::getDisplayAttributesString($itemAttributes, $item, 'cart')
209 - : '';
210 -
211 - $item['variation_display_title'] = $variationDisplayTitle !== ''
212 - ? $variationDisplayTitle
213 - : (string) Arr::get($item, 'title', '');
214 -
215 255 return $item;
216 256 }, $items);
217 257 }
218 258
@@ -261,8 +301,38 @@
261 301 {
262 302 return Arr::get($this->checkout_data, 'is_locked') === 'yes' && $this->order_id;
263 303 }
264 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 +
265 335 public function addItem($item = [], $replacingIndex = null)
266 336 {
267 337 if ($this->isLocked()) {
268 338 return new \WP_Error('cart_locked', __('This cart is locked and cannot be modified.', 'fluent-cart'));
@@ -341,9 +411,14 @@
341 411 }
342 412
343 413 public function addByVariation(ProductVariation $variation, $config = [])
344 414 {
345 - $quantity = (int)Arr::get($config, 'quantity', 1);
415 + $quantity = Arr::get($config, 'quantity', 1);
416 + $error = CartHelper::validateQuantity($quantity, true);
417 + if ($error) {
418 + return $error;
419 + }
420 + $quantity = (int)$quantity;
346 421 $byInput = Arr::get($config, 'by_input', false);
347 422
348 423 if ($quantity == 0) {
349 424 // that means we have to remove it
@@ -349,8 +424,12 @@
349 424 // that means we have to remove it
350 425 return $this->removeItem($variation->id, Arr::get($config, 'remove_args', []), true);
351 426 }
352 427
428 + if (!$variation->product) {
429 + return new \WP_Error('product_not_found', __('This product is no longer available.', 'fluent-cart'));
430 + }
431 +
353 432 $validate = Arr::get($config, 'will_validate', false);
354 433
355 434 $replacingIndex = null;
356 435
@@ -383,8 +462,14 @@
383 462 // remove the item if quantity is zero or negative after adjustment
384 463 return $this->removeItem($variation->id);
385 464 }
386 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 +
387 472 if ($validate) {
388 473 $canPurchase = $variation->canPurchase($quantity);
389 474 $canPurchase = apply_filters('fluent_cart/cart/can_purchase', $canPurchase, [
390 475 'cart' => $this,
@@ -432,9 +517,14 @@
432 517 __('Invalid custom item data.', 'fluent-cart')
433 518 );
434 519 }
435 520
436 - $quantity = (int)Arr::get($config, 'quantity', 1);
521 + $quantity = Arr::get($config, 'quantity', 1);
522 + $error = CartHelper::validateQuantity($quantity, true);
523 + if ($error) {
524 + return $error;
525 + }
526 + $quantity = (int)$quantity;
437 527 $variationId = Arr::get($variation, 'id');
438 528
439 529 if ($quantity == 0) {
440 530 // that means we have to remove it
@@ -467,10 +557,10 @@
467 557 return $this->removeItem($variationId);
468 558 }
469 559 }
470 560
471 - // Subscription items may exist in cart,
472 - // but checkout must be initiated via direct checkout flow to ensure proper handling.
561 + // Subscription items may exist in cart,
562 + // but checkout must be initiated via direct checkout flow to ensure proper handling.
473 563 if (Arr::get($variation, 'payment_type', null) === 'subscription') {
474 564 return new \WP_Error('invalid_item', __('Subscription items must be purchased via direct checkout.', 'fluent-cart'));
475 565
476 566 }
@@ -475,9 +565,9 @@
475 565
476 566 }
477 567
478 568 // Find existing item in cart
479 - $replacingIndex = null;
569 + $replacingIndex = null;
480 570 $existingItem = $this->findExistingItemAndIndex(
481 571 $variationId,
482 572 Arr::get($config, 'matched_args', [])
483 573 );