| @@ -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, |
| @@ -86,11 +111,28 @@ | ||
| 86 | 111 | if(!$isCustom) { |
| 87 | 112 | $cart = CartHelper::generateCartFromVariation($variation, $quantity); |
| 88 | 113 | } |
| 89 | 114 | else { |
| 90 | - // TODO: Legacy object-to-array conversion. Kept for backward compatibility. | |
| 115 | + // Legacy object-to-array conversion. Kept for backward compatibility. | |
| 91 | 116 | $cart = CartHelper::generateCartFromCustomVariation(json_decode(json_encode($variation), true), $quantity); |
| 92 | 117 | } |
| 118 | + } else { | |
| 119 | + // Refresh cart_data from the current variation on every instant hit. | |
| 120 | + // The inputs (variationId + quantity) are deterministic URL params, | |
| 121 | + // so regenerating is idempotent — and it picks up any fields that | |
| 122 | + // were missing on a previously-created draft (variation_type for | |
| 123 | + // advanced-variation rows, refreshed pricing, updated featured | |
| 124 | + // media). Without this, a stale draft from before a code change | |
| 125 | + // keeps rendering with its old shape. | |
| 126 | + if (!$isCustom) { | |
| 127 | + $cart->cart_data = [ | |
| 128 | + CartHelper::generateCartItemFromVariation($variation, $quantity) | |
| 129 | + ]; | |
| 130 | + } else { | |
| 131 | + $cart->cart_data = [ | |
| 132 | + CartHelper::generateCartItemCustomItem(json_decode(json_encode($variation), true), $quantity) | |
| 133 | + ]; | |
| 134 | + } | |
| 93 | 135 | } |
| 94 | 136 | |
| 95 | 137 | if (is_user_logged_in()) { |
| 96 | 138 | $cart->user_id = get_current_user_id(); |
| @@ -119,11 +161,10 @@ | ||
| 119 | 161 | * |
| 120 | 162 | */ |
| 121 | 163 | public static function get(array $params = []) |
| 122 | 164 | { |
| 123 | - static $cart; | |
| 124 | - if (isset($cart)) { | |
| 125 | - return $cart; | |
| 165 | + if (static::$cartCache !== false && static::$cartCache !== null) { | |
| 166 | + return static::$cartCache; | |
| 126 | 167 | } |
| 127 | 168 | |
| 128 | 169 | $autoCreate = Arr::get($params, 'create', false); |
| 129 | 170 | |
| @@ -138,9 +179,9 @@ | ||
| 138 | 179 | ->where('cart_group', 'instant'); |
| 139 | 180 | |
| 140 | 181 | $tempCart = $cartQuery->first(); |
| 141 | 182 | |
| 142 | - $cart = $tempCart; | |
| 183 | + static::$cartCache = $tempCart; | |
| 143 | 184 | |
| 144 | 185 | if (!$autoCreate) { |
| 145 | 186 | return $tempCart; |
| 146 | 187 | } |
| @@ -145,11 +186,11 @@ | ||
| 145 | 186 | return $tempCart; |
| 146 | 187 | } |
| 147 | 188 | } |
| 148 | 189 | |
| 149 | - $cart = static::getOrSetCartForThisDevice($autoCreate); | |
| 190 | + static::$cartCache = static::getOrSetCartForThisDevice($autoCreate); | |
| 150 | 191 | |
| 151 | - return $cart; | |
| 192 | + return static::$cartCache; | |
| 152 | 193 | } |
| 153 | 194 | |
| 154 | 195 | public static function find($id, $params = []) |
| 155 | 196 | { |
| @@ -310,9 +351,21 @@ | ||
| 310 | 351 | ]); |
| 311 | 352 | } |
| 312 | 353 | |
| 313 | 354 | if (!$variation) { |
| 314 | - return $cart->removeItem($itemId); | |
| 355 | + // An item already in the cart whose variation row has since | |
| 356 | + // disappeared (product/variation deleted) is dropped gracefully. | |
| 357 | + // An id that was never in the cart and resolves to nothing is a | |
| 358 | + // client error — silently answering "Cart updated successfully" | |
| 359 | + // hid typos and probing as a 200 no-op. | |
| 360 | + if ($existingItem !== null) { | |
| 361 | + return $cart->removeItem($itemId); | |
| 362 | + } | |
| 363 | + | |
| 364 | + return new WP_Error( | |
| 365 | + 'invalid_item', | |
| 366 | + __('Invalid item.', 'fluent-cart') | |
| 367 | + ); | |
| 315 | 368 | } |
| 316 | 369 | |
| 317 | 370 | $soldIndividually = $isCustom |
| 318 | 371 | ? !empty($variation->sold_individually) |
| @@ -351,9 +404,13 @@ | ||
| 351 | 404 | } |
| 352 | 405 | |
| 353 | 406 | $utmData = static::prepareUtmData($data); |
| 354 | 407 | if ($utmData) { |
| 355 | - $cart->utm_data = array_merge(is_array($cart->utm_data) ? $cart->utm_data : [], $utmData); | |
| 408 | + // Replaced, not merged. A cart row is reused across visits, so merging | |
| 409 | + // key by key accumulated a union of every touch that ever reached it and | |
| 410 | + // the column stopped describing any single one. The browser has already | |
| 411 | + // resolved which touch this is, so its block is the answer. | |
| 412 | + $cart->utm_data = $utmData; | |
| 356 | 413 | $cart->save(); |
| 357 | 414 | } |
| 358 | 415 | |
| 359 | 416 | return $cart; |
| @@ -565,8 +622,30 @@ | ||
| 565 | 622 | Arr::get($productVariation, 'variation_title') |
| 566 | 623 | ), |
| 567 | 624 | ]; |
| 568 | 625 | } |
| 626 | + | |
| 627 | + $paymentType = $productVariation instanceof ProductVariation | |
| 628 | + ? $productVariation->payment_type | |
| 629 | + : Arr::get($productVariation, 'payment_type'); | |
| 630 | + | |
| 631 | + if ($paymentType === 'subscription' && $quantity > 1) { | |
| 632 | + return [ | |
| 633 | + 'code' => 'failed', | |
| 634 | + 'message' => __('You cannot purchase more than one subscription at a time.', 'fluent-cart'), | |
| 635 | + ]; | |
| 636 | + } | |
| 637 | + | |
| 638 | + if (!empty($existingItemsArray)) { | |
| 639 | + $hasSubscription = static::hasSubscriptionProduct($existingItemsArray); | |
| 640 | + | |
| 641 | + if ($paymentType === 'subscription' || $hasSubscription) { | |
| 642 | + return [ | |
| 643 | + 'code' => 'failed', | |
| 644 | + 'message' => __("Subscription items can't be combined with other products in the cart.", 'fluent-cart'), | |
| 645 | + ]; | |
| 646 | + } | |
| 647 | + } | |
| 569 | 648 | } |
| 570 | 649 | |
| 571 | 650 | if ($productVariation instanceof ProductVariation) { |
| 572 | 651 | $item = CartHelper::generateCartItemFromVariation($productVariation, $quantity); |
| @@ -699,12 +778,15 @@ | ||
| 699 | 778 | } |
| 700 | 779 | |
| 701 | 780 | $userId = get_current_user_id(); |
| 702 | 781 | if ($userId) { |
| 782 | + // Latest cart first — without an order, first() picks by primary key | |
| 783 | + // (cart_hash), which resurrects an arbitrary old cart for the user. | |
| 703 | 784 | $cart = static::getQuery() |
| 704 | 785 | ->where('user_id', $userId) |
| 705 | 786 | ->where('stage', '!=', 'completed') |
| 706 | 787 | ->where('cart_group', 'global') |
| 788 | + ->orderBy('updated_at', 'DESC') | |
| 707 | 789 | ->first(); |
| 708 | 790 | |
| 709 | 791 | if ($cart) { |
| 710 | 792 | return $cart; |