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 +183 -22 1.3.25 → 1.7.1 View file →
@@ -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'));
@@ -297,9 +411,14 @@
297 411 }
298 412
299 413 public function addByVariation(ProductVariation $variation, $config = [])
300 414 {
301 - $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;
302 421 $byInput = Arr::get($config, 'by_input', false);
303 422
304 423 if ($quantity == 0) {
305 424 // that means we have to remove it
@@ -305,8 +424,12 @@
305 424 // that means we have to remove it
306 425 return $this->removeItem($variation->id, Arr::get($config, 'remove_args', []), true);
307 426 }
308 427
428 + if (!$variation->product) {
429 + return new \WP_Error('product_not_found', __('This product is no longer available.', 'fluent-cart'));
430 + }
431 +
309 432 $validate = Arr::get($config, 'will_validate', false);
310 433
311 434 $replacingIndex = null;
312 435
@@ -339,8 +462,14 @@
339 462 // remove the item if quantity is zero or negative after adjustment
340 463 return $this->removeItem($variation->id);
341 464 }
342 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 +
343 472 if ($validate) {
344 473 $canPurchase = $variation->canPurchase($quantity);
345 474 $canPurchase = apply_filters('fluent_cart/cart/can_purchase', $canPurchase, [
346 475 'cart' => $this,
@@ -353,8 +482,14 @@
353 482
354 483 if ($this->isLocked()) {
355 484 return new \WP_Error('cart_locked', __('This cart is locked and cannot be modified.', 'fluent-cart'));
356 485 }
486 +
487 + if ($replacingIndex === null && !empty($this->cart_data)) {
488 + if ($variation->payment_type === 'subscription' || $this->hasSubscription()) {
489 + return new \WP_Error('subscription_items_can_not_combined', __("Subscription items can't be combined with other products in the cart.", 'fluent-cart'));
490 + }
491 + }
357 492 }
358 493
359 494 $item = CartHelper::generateCartItemFromVariation($variation, $quantity);
360 495 $otherInfoExtras = Arr::get($config, 'other_info', []);
@@ -382,9 +517,14 @@
382 517 __('Invalid custom item data.', 'fluent-cart')
383 518 );
384 519 }
385 520
386 - $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;
387 527 $variationId = Arr::get($variation, 'id');
388 528
389 529 if ($quantity == 0) {
390 530 // that means we have to remove it
@@ -417,10 +557,10 @@
417 557 return $this->removeItem($variationId);
418 558 }
419 559 }
420 560
421 - // Subscription items may exist in cart,
422 - // 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.
423 563 if (Arr::get($variation, 'payment_type', null) === 'subscription') {
424 564 return new \WP_Error('invalid_item', __('Subscription items must be purchased via direct checkout.', 'fluent-cart'));
425 565
426 566 }
@@ -425,9 +565,9 @@
425 565
426 566 }
427 567
428 568 // Find existing item in cart
429 - $replacingIndex = null;
569 + $replacingIndex = null;
430 570 $existingItem = $this->findExistingItemAndIndex(
431 571 $variationId,
432 572 Arr::get($config, 'matched_args', [])
433 573 );
@@ -646,8 +786,17 @@
646 786 }
647 787
648 788 $coupons = Coupon::whereIn('code', $this->coupons)->get();
649 789
790 + /*
791 + * Let addons resolve virtual (un-persisted) coupon codes into in-memory Coupon
792 + * models so they appear in the summary discount line like any coupon. See
793 + * DiscountService::applyCouponCodes() for the same filter.
794 + */
795 + $coupons = apply_filters('fluent_cart/coupon/resolve_coupons', $coupons, $this->coupons, [
796 + 'cart' => $this,
797 + ]);
798 +
650 799 if ($coupons->isEmpty()) {
651 800 return [];
652 801 }
653 802
@@ -839,14 +988,15 @@
839 988 return false;
840 989 }
841 990
842 991 $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'] ?? []),
992 + 'key' => sanitize_key($fee['key']),
993 + 'label' => sanitize_text_field($fee['label']),
994 + 'amount' => $amount,
995 + 'taxable' => !empty($fee['taxable']),
996 + 'inclusive' => !empty($fee['inclusive']),
997 + 'source' => sanitize_key($fee['source'] ?? 'custom'),
998 + 'meta' => (array) ($fee['meta'] ?? []),
849 999 ];
850 1000
851 1001 $checkoutData = $this->checkout_data ?? [];
852 1002 $fees = (array) Arr::get($checkoutData, 'fees', []);
@@ -1034,14 +1184,15 @@
1034 1184 $compositeKey = $source . ':' . sanitize_key($fee['key']);
1035 1185
1036 1186 // Last wins — later entries (from filter) override earlier ones (stored)
1037 1187 $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'] ?? []),
1188 + 'key' => sanitize_key($fee['key']),
1189 + 'label' => sanitize_text_field($fee['label']),
1190 + 'amount' => $amount,
1191 + 'taxable' => !empty($fee['taxable']),
1192 + 'inclusive' => !empty($fee['inclusive']),
1193 + 'source' => $source,
1194 + 'meta' => (array) ($fee['meta'] ?? []),
1044 1195 ];
1045 1196 }
1046 1197
1047 1198 return array_values($validFees);
@@ -1110,8 +1261,15 @@
1110 1261 $finalTotal = apply_filters('fluent_cart/cart/estimated_total', $total, [
1111 1262 'cart' => $this
1112 1263 ]);
1113 1264
1265 + // Prorate credit and upgrade discount (plan upgrade) are post-tax adjustments: the
1266 + // estimated_total filter has already added tax on the full price, now reduce the
1267 + // payable total.
1268 + $finalTotal = max(0, $finalTotal
1269 + - (int) Arr::get($this->checkout_data ?? [], 'prorate_credit.amount', 0)
1270 + - (int) Arr::get($this->checkout_data ?? [], 'upgrade_discount.amount', 0));
1271 +
1114 1272 do_action('fluent_cart/cart/after_totals_calculation', [
1115 1273 'cart' => $this,
1116 1274 'total' => $finalTotal,
1117 1275 ]);
@@ -1138,8 +1296,11 @@
1138 1296 $feeTotal = $this->getFeeTotal();
1139 1297 if ($feeTotal > 0) {
1140 1298 $total += $feeTotal;
1141 1299 }
1300 +
1301 + $total -= (int) Arr::get($this->checkout_data ?? [], 'prorate_credit.amount', 0);
1302 + $total -= (int) Arr::get($this->checkout_data ?? [], 'upgrade_discount.amount', 0);
1142 1303
1143 1304 return max(0, $total);
1144 1305 }
1145 1306