| @@ -19,8 +19,29 @@ | ||
| 19 | 19 | |
| 20 | 20 | class CartResource extends BaseResourceApi |
| 21 | 21 | { |
| 22 | 22 | |
| 23 | + /** | |
| 24 | + * Per-request memo for get(). In production every HTTP request runs in a | |
| 25 | + * fresh PHP process, so this lives exactly one request. Long-running | |
| 26 | + * processes that simulate multiple requests (test suites, CLI) must clear | |
| 27 | + * it between simulated requests via resetCartCache() — as a | |
| 28 | + * function-static it was unreachable and leaked the first request's cart | |
| 29 | + * into every subsequent one. | |
| 30 | + * | |
| 31 | + * Only a resolved Cart is memoized; a null ("no cart") result is | |
| 32 | + * deliberately re-queried on the next call — matching the original | |
| 33 | + * function-static behavior, where isset(null) === false. | |
| 34 | + * | |
| 35 | + * @var Cart|null|false false = not resolved yet | |
| 36 | + */ | |
| 37 | + private static $cartCache = false; | |
| 38 | + | |
| 39 | + public static function resetCartCache(): void | |
| 40 | + { | |
| 41 | + static::$cartCache = false; | |
| 42 | + } | |
| 43 | + | |
| 23 | 44 | public static function getQuery(): Builder |
| 24 | 45 | { |
| 25 | 46 | return Cart::query(); |
| 26 | 47 | } |
| @@ -35,11 +56,15 @@ | ||
| 35 | 56 | $variation = Arr::get($params, 'variation'); |
| 36 | 57 | $variation = CartHelper::normalizeCustomFields($variation); |
| 37 | 58 | } |
| 38 | 59 | else { |
| 60 | + // product_detail must be eager-loaded — generateCartItemFromVariation | |
| 61 | + // reads $variation['product_detail']['variation_type'] to stamp | |
| 62 | + // the cart item's variation_type. Without it the field is null on | |
| 63 | + // instant-checkout carts and CartRenderer can't tell whether to | |
| 64 | + // hide the variant-title line for simple products. | |
| 39 | 65 | $variation = ProductVariation::query() |
| 40 | - ->with(['product']) | |
| 41 | - ->with(['media', 'shippingClass']) | |
| 66 | + ->with(['product', 'product_detail', 'media', 'shippingClass']) | |
| 42 | 67 | ->where('id', $variationId)->first(); |
| 43 | 68 | |
| 44 | 69 | $variation = apply_filters('fluent_cart/cart/item_modify', $variation, [ |
| 45 | 70 | 'item_id' => $variationId, |
| @@ -55,8 +80,16 @@ | ||
| 55 | 80 | 'variation' => $variation, |
| 56 | 81 | 'product' => !$isCustom ? $variation->product : [] |
| 57 | 82 | ]); |
| 58 | 83 | |
| 84 | + // After the filter, not before: this path takes its quantity straight from a | |
| 85 | + // public URL param, and the filter above can replace it with anything. | |
| 86 | + $error = CartHelper::validateQuantity($quantity); | |
| 87 | + if ($error) { | |
| 88 | + return $error; | |
| 89 | + } | |
| 90 | + $quantity = (int)$quantity; | |
| 91 | + | |
| 59 | 92 | if ($variation->payment_type === 'subscription') { |
| 60 | 93 | $quantity = 1; |
| 61 | 94 | } |
| 62 | 95 | |
| @@ -86,11 +119,28 @@ | ||
| 86 | 119 | if(!$isCustom) { |
| 87 | 120 | $cart = CartHelper::generateCartFromVariation($variation, $quantity); |
| 88 | 121 | } |
| 89 | 122 | else { |
| 90 | - // TODO: Legacy object-to-array conversion. Kept for backward compatibility. | |
| 123 | + // Legacy object-to-array conversion. Kept for backward compatibility. | |
| 91 | 124 | $cart = CartHelper::generateCartFromCustomVariation(json_decode(json_encode($variation), true), $quantity); |
| 92 | 125 | } |
| 126 | + } else { | |
| 127 | + // Refresh cart_data from the current variation on every instant hit. | |
| 128 | + // The inputs (variationId + quantity) are deterministic URL params, | |
| 129 | + // so regenerating is idempotent — and it picks up any fields that | |
| 130 | + // were missing on a previously-created draft (variation_type for | |
| 131 | + // advanced-variation rows, refreshed pricing, updated featured | |
| 132 | + // media). Without this, a stale draft from before a code change | |
| 133 | + // keeps rendering with its old shape. | |
| 134 | + if (!$isCustom) { | |
| 135 | + $cart->cart_data = [ | |
| 136 | + CartHelper::generateCartItemFromVariation($variation, $quantity) | |
| 137 | + ]; | |
| 138 | + } else { | |
| 139 | + $cart->cart_data = [ | |
| 140 | + CartHelper::generateCartItemCustomItem(json_decode(json_encode($variation), true), $quantity) | |
| 141 | + ]; | |
| 142 | + } | |
| 93 | 143 | } |
| 94 | 144 | |
| 95 | 145 | if (is_user_logged_in()) { |
| 96 | 146 | $cart->user_id = get_current_user_id(); |
| @@ -119,11 +169,10 @@ | ||
| 119 | 169 | * |
| 120 | 170 | */ |
| 121 | 171 | public static function get(array $params = []) |
| 122 | 172 | { |
| 123 | - static $cart; | |
| 124 | - if (isset($cart)) { | |
| 125 | - return $cart; | |
| 173 | + if (static::$cartCache !== false && static::$cartCache !== null) { | |
| 174 | + return static::$cartCache; | |
| 126 | 175 | } |
| 127 | 176 | |
| 128 | 177 | $autoCreate = Arr::get($params, 'create', false); |
| 129 | 178 | |
| @@ -138,9 +187,9 @@ | ||
| 138 | 187 | ->where('cart_group', 'instant'); |
| 139 | 188 | |
| 140 | 189 | $tempCart = $cartQuery->first(); |
| 141 | 190 | |
| 142 | - $cart = $tempCart; | |
| 191 | + static::$cartCache = $tempCart; | |
| 143 | 192 | |
| 144 | 193 | if (!$autoCreate) { |
| 145 | 194 | return $tempCart; |
| 146 | 195 | } |
| @@ -145,11 +194,11 @@ | ||
| 145 | 194 | return $tempCart; |
| 146 | 195 | } |
| 147 | 196 | } |
| 148 | 197 | |
| 149 | - $cart = static::getOrSetCartForThisDevice($autoCreate); | |
| 198 | + static::$cartCache = static::getOrSetCartForThisDevice($autoCreate); | |
| 150 | 199 | |
| 151 | - return $cart; | |
| 200 | + return static::$cartCache; | |
| 152 | 201 | } |
| 153 | 202 | |
| 154 | 203 | public static function find($id, $params = []) |
| 155 | 204 | { |
| @@ -174,11 +223,13 @@ | ||
| 174 | 223 | { |
| 175 | 224 | $itemId = Arr::get($data, 'id'); |
| 176 | 225 | $quantity = Arr::get($data, 'quantity', 1); |
| 177 | 226 | |
| 178 | - if ($quantity <= 0) { | |
| 227 | + // This path writes cart_data directly instead of going through Cart::addItem(). | |
| 228 | + $error = CartHelper::validateQuantity($quantity); | |
| 229 | + if ($error) { | |
| 179 | 230 | return static::makeErrorResponse([ |
| 180 | - ['code' => 403, 'message' => __('Quantity can not be negative.', 'fluent-cart')] | |
| 231 | + ['code' => 403, 'message' => $error->get_error_message()] | |
| 181 | 232 | ]); |
| 182 | 233 | } |
| 183 | 234 | |
| 184 | 235 | $cart = CartResource::get([ |
| @@ -310,9 +361,21 @@ | ||
| 310 | 361 | ]); |
| 311 | 362 | } |
| 312 | 363 | |
| 313 | 364 | if (!$variation) { |
| 314 | - return $cart->removeItem($itemId); | |
| 365 | + // An item already in the cart whose variation row has since | |
| 366 | + // disappeared (product/variation deleted) is dropped gracefully. | |
| 367 | + // An id that was never in the cart and resolves to nothing is a | |
| 368 | + // client error — silently answering "Cart updated successfully" | |
| 369 | + // hid typos and probing as a 200 no-op. | |
| 370 | + if ($existingItem !== null) { | |
| 371 | + return $cart->removeItem($itemId); | |
| 372 | + } | |
| 373 | + | |
| 374 | + return new WP_Error( | |
| 375 | + 'invalid_item', | |
| 376 | + __('Invalid item.', 'fluent-cart') | |
| 377 | + ); | |
| 315 | 378 | } |
| 316 | 379 | |
| 317 | 380 | $soldIndividually = $isCustom |
| 318 | 381 | ? !empty($variation->sold_individually) |
| @@ -351,9 +414,13 @@ | ||
| 351 | 414 | } |
| 352 | 415 | |
| 353 | 416 | $utmData = static::prepareUtmData($data); |
| 354 | 417 | if ($utmData) { |
| 355 | - $cart->utm_data = array_merge(is_array($cart->utm_data) ? $cart->utm_data : [], $utmData); | |
| 418 | + // Replaced, not merged. A cart row is reused across visits, so merging | |
| 419 | + // key by key accumulated a union of every touch that ever reached it and | |
| 420 | + // the column stopped describing any single one. The browser has already | |
| 421 | + // resolved which touch this is, so its block is the answer. | |
| 422 | + $cart->utm_data = $utmData; | |
| 356 | 423 | $cart->save(); |
| 357 | 424 | } |
| 358 | 425 | |
| 359 | 426 | return $cart; |
| @@ -524,8 +591,15 @@ | ||
| 524 | 591 | if ($updatedQuantity < 0) { |
| 525 | 592 | $updatedQuantity = 0; |
| 526 | 593 | } |
| 527 | 594 | |
| 595 | + if ($updatedQuantity > 0 && ($error = CartHelper::validateQuantity($updatedQuantity))) { | |
| 596 | + return [ | |
| 597 | + 'code' => 'failed', | |
| 598 | + 'message' => $error->get_error_message() | |
| 599 | + ]; | |
| 600 | + } | |
| 601 | + | |
| 528 | 602 | if (!$isFilteredItem) { |
| 529 | 603 | |
| 530 | 604 | if (!CartHelper::shouldAddItemToCart($productVariation, $updatedQuantity)) { |
| 531 | 605 | return [ |
| @@ -554,8 +628,16 @@ | ||
| 554 | 628 | |
| 555 | 629 | if ($quantity < 1) { |
| 556 | 630 | $quantity = 1; |
| 557 | 631 | } |
| 632 | + | |
| 633 | + if ($error = CartHelper::validateQuantity($quantity)) { | |
| 634 | + return [ | |
| 635 | + 'code' => 'failed', | |
| 636 | + 'message' => $error->get_error_message() | |
| 637 | + ]; | |
| 638 | + } | |
| 639 | + | |
| 558 | 640 | if (!$isFilteredItem) { |
| 559 | 641 | if (!CartHelper::shouldAddItemToCart($productVariation, $quantity)) { |
| 560 | 642 | return [ |
| 561 | 643 | 'code' => 'failed', |
| @@ -565,8 +647,30 @@ | ||
| 565 | 647 | Arr::get($productVariation, 'variation_title') |
| 566 | 648 | ), |
| 567 | 649 | ]; |
| 568 | 650 | } |
| 651 | + | |
| 652 | + $paymentType = $productVariation instanceof ProductVariation | |
| 653 | + ? $productVariation->payment_type | |
| 654 | + : Arr::get($productVariation, 'payment_type'); | |
| 655 | + | |
| 656 | + if ($paymentType === 'subscription' && $quantity > 1) { | |
| 657 | + return [ | |
| 658 | + 'code' => 'failed', | |
| 659 | + 'message' => __('You cannot purchase more than one subscription at a time.', 'fluent-cart'), | |
| 660 | + ]; | |
| 661 | + } | |
| 662 | + | |
| 663 | + if (!empty($existingItemsArray)) { | |
| 664 | + $hasSubscription = static::hasSubscriptionProduct($existingItemsArray); | |
| 665 | + | |
| 666 | + if ($paymentType === 'subscription' || $hasSubscription) { | |
| 667 | + return [ | |
| 668 | + 'code' => 'failed', | |
| 669 | + 'message' => __("Subscription items can't be combined with other products in the cart.", 'fluent-cart'), | |
| 670 | + ]; | |
| 671 | + } | |
| 672 | + } | |
| 569 | 673 | } |
| 570 | 674 | |
| 571 | 675 | if ($productVariation instanceof ProductVariation) { |
| 572 | 676 | $item = CartHelper::generateCartItemFromVariation($productVariation, $quantity); |
| @@ -699,12 +803,15 @@ | ||
| 699 | 803 | } |
| 700 | 804 | |
| 701 | 805 | $userId = get_current_user_id(); |
| 702 | 806 | if ($userId) { |
| 807 | + // Latest cart first — without an order, first() picks by primary key | |
| 808 | + // (cart_hash), which resurrects an arbitrary old cart for the user. | |
| 703 | 809 | $cart = static::getQuery() |
| 704 | 810 | ->where('user_id', $userId) |
| 705 | 811 | ->where('stage', '!=', 'completed') |
| 706 | 812 | ->where('cart_group', 'global') |
| 813 | + ->orderBy('updated_at', 'DESC') | |
| 707 | 814 | ->first(); |
| 708 | 815 | |
| 709 | 816 | if ($cart) { |
| 710 | 817 | return $cart; |