| @@ -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 | ); |