| @@ -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 | |
| @@ -353,8 +471,14 @@ | ||
| 353 | 471 | |
| 354 | 472 | if ($this->isLocked()) { |
| 355 | 473 | return new \WP_Error('cart_locked', __('This cart is locked and cannot be modified.', 'fluent-cart')); |
| 356 | 474 | } |
| 475 | + | |
| 476 | + if ($replacingIndex === null && !empty($this->cart_data)) { | |
| 477 | + if ($variation->payment_type === 'subscription' || $this->hasSubscription()) { | |
| 478 | + return new \WP_Error('subscription_items_can_not_combined', __("Subscription items can't be combined with other products in the cart.", 'fluent-cart')); | |
| 479 | + } | |
| 480 | + } | |
| 357 | 481 | } |
| 358 | 482 | |
| 359 | 483 | $item = CartHelper::generateCartItemFromVariation($variation, $quantity); |
| 360 | 484 | $otherInfoExtras = Arr::get($config, 'other_info', []); |
| @@ -417,10 +541,10 @@ | ||
| 417 | 541 | return $this->removeItem($variationId); |
| 418 | 542 | } |
| 419 | 543 | } |
| 420 | 544 | |
| 421 | - // Subscription items may exist in cart, | |
| 422 | - // 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. | |
| 423 | 547 | if (Arr::get($variation, 'payment_type', null) === 'subscription') { |
| 424 | 548 | return new \WP_Error('invalid_item', __('Subscription items must be purchased via direct checkout.', 'fluent-cart')); |
| 425 | 549 | |
| 426 | 550 | } |
| @@ -425,9 +549,9 @@ | ||
| 425 | 549 | |
| 426 | 550 | } |
| 427 | 551 | |
| 428 | 552 | // Find existing item in cart |
| 429 | - $replacingIndex = null; | |
| 553 | + $replacingIndex = null; | |
| 430 | 554 | $existingItem = $this->findExistingItemAndIndex( |
| 431 | 555 | $variationId, |
| 432 | 556 | Arr::get($config, 'matched_args', []) |
| 433 | 557 | ); |
| @@ -646,8 +770,17 @@ | ||
| 646 | 770 | } |
| 647 | 771 | |
| 648 | 772 | $coupons = Coupon::whereIn('code', $this->coupons)->get(); |
| 649 | 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 | + ]); | |
| 782 | + | |
| 650 | 783 | if ($coupons->isEmpty()) { |
| 651 | 784 | return []; |
| 652 | 785 | } |
| 653 | 786 | |
| @@ -839,14 +972,15 @@ | ||
| 839 | 972 | return false; |
| 840 | 973 | } |
| 841 | 974 | |
| 842 | 975 | $validatedFee = [ |
| 843 | - 'key' => sanitize_key($fee['key']), | |
| 844 | - 'label' => sanitize_text_field($fee['label']), | |
| 845 | - 'amount' => $amount, | |
| 846 | - 'taxable' => !empty($fee['taxable']), | |
| 847 | - 'source' => sanitize_key($fee['source'] ?? 'custom'), | |
| 848 | - 'meta' => (array) ($fee['meta'] ?? []), | |
| 976 | + 'key' => sanitize_key($fee['key']), | |
| 977 | + 'label' => sanitize_text_field($fee['label']), | |
| 978 | + 'amount' => $amount, | |
| 979 | + 'taxable' => !empty($fee['taxable']), | |
| 980 | + 'inclusive' => !empty($fee['inclusive']), | |
| 981 | + 'source' => sanitize_key($fee['source'] ?? 'custom'), | |
| 982 | + 'meta' => (array) ($fee['meta'] ?? []), | |
| 849 | 983 | ]; |
| 850 | 984 | |
| 851 | 985 | $checkoutData = $this->checkout_data ?? []; |
| 852 | 986 | $fees = (array) Arr::get($checkoutData, 'fees', []); |
| @@ -1034,14 +1168,15 @@ | ||
| 1034 | 1168 | $compositeKey = $source . ':' . sanitize_key($fee['key']); |
| 1035 | 1169 | |
| 1036 | 1170 | // Last wins — later entries (from filter) override earlier ones (stored) |
| 1037 | 1171 | $validFees[$compositeKey] = [ |
| 1038 | - 'key' => sanitize_key($fee['key']), | |
| 1039 | - 'label' => sanitize_text_field($fee['label']), | |
| 1040 | - 'amount' => $amount, | |
| 1041 | - 'taxable' => !empty($fee['taxable']), | |
| 1042 | - 'source' => $source, | |
| 1043 | - 'meta' => (array) ($fee['meta'] ?? []), | |
| 1172 | + 'key' => sanitize_key($fee['key']), | |
| 1173 | + 'label' => sanitize_text_field($fee['label']), | |
| 1174 | + 'amount' => $amount, | |
| 1175 | + 'taxable' => !empty($fee['taxable']), | |
| 1176 | + 'inclusive' => !empty($fee['inclusive']), | |
| 1177 | + 'source' => $source, | |
| 1178 | + 'meta' => (array) ($fee['meta'] ?? []), | |
| 1044 | 1179 | ]; |
| 1045 | 1180 | } |
| 1046 | 1181 | |
| 1047 | 1182 | return array_values($validFees); |
| @@ -1110,8 +1245,15 @@ | ||
| 1110 | 1245 | $finalTotal = apply_filters('fluent_cart/cart/estimated_total', $total, [ |
| 1111 | 1246 | 'cart' => $this |
| 1112 | 1247 | ]); |
| 1113 | 1248 | |
| 1249 | + // Prorate credit and upgrade discount (plan upgrade) are post-tax adjustments: the | |
| 1250 | + // estimated_total filter has already added tax on the full price, now reduce the | |
| 1251 | + // payable total. | |
| 1252 | + $finalTotal = max(0, $finalTotal | |
| 1253 | + - (int) Arr::get($this->checkout_data ?? [], 'prorate_credit.amount', 0) | |
| 1254 | + - (int) Arr::get($this->checkout_data ?? [], 'upgrade_discount.amount', 0)); | |
| 1255 | + | |
| 1114 | 1256 | do_action('fluent_cart/cart/after_totals_calculation', [ |
| 1115 | 1257 | 'cart' => $this, |
| 1116 | 1258 | 'total' => $finalTotal, |
| 1117 | 1259 | ]); |
| @@ -1138,8 +1280,11 @@ | ||
| 1138 | 1280 | $feeTotal = $this->getFeeTotal(); |
| 1139 | 1281 | if ($feeTotal > 0) { |
| 1140 | 1282 | $total += $feeTotal; |
| 1141 | 1283 | } |
| 1284 | + | |
| 1285 | + $total -= (int) Arr::get($this->checkout_data ?? [], 'prorate_credit.amount', 0); | |
| 1286 | + $total -= (int) Arr::get($this->checkout_data ?? [], 'upgrade_discount.amount', 0); | |
| 1142 | 1287 | |
| 1143 | 1288 | return max(0, $total); |
| 1144 | 1289 | } |
| 1145 | 1290 | |