PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
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 1.3.20 1.3.19 All 49 releases
← All changes | app/Models/Cart.php +165 -20 1.3.26 → 1.6.6 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'));
@@ -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