PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.21
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.21
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 trunk All 48 releases
fluent-cart / app / Models / ProductVariation.php

ProductVariation.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.3.21, at app/Models/ProductVariation.php

408 lines 12.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Models;
4
5 use FluentCart\Api\ModuleSettings;
6 use FluentCart\App\App;
7 use FluentCart\App\Helpers\Helper;
8 use FluentCart\App\Helpers\Status;
9 use FluentCart\App\Models\Concerns\CanSearch;
10 use FluentCart\App\Models\Concerns\CanUpdateBatch;
11 use FluentCart\App\Models\Relations\BundleChildrenRelation;
12 use FluentCart\App\Services\PlanUpgradeService;
13 use FluentCart\App\Services\URL;
14 use FluentCart\Framework\Database\Orm\Builder;
15 use FluentCart\Framework\Support\Arr;
16
17 /**
18 * Product Details Model - DB Model for Product Details
19 *
20 * Database Model
21 *
22 *
23 * @package FluentCart\App\Models
24 *
25 * @version 1.0.0
26 */
27 class ProductVariation extends Model
28 {
29
30 use CanSearch, CanUpdateBatch;
31
32 protected $table = 'fct_product_variations';
33
34 protected $fillable = [
35 'post_id',
36 'media_id',
37 'serial_index',
38 'sold_individually',
39 'variation_title',
40 'variation_identifier',
41 'sku',
42 'manage_stock',
43 'payment_type',
44 'stock_status',
45 'backorders',
46 'total_stock',
47 'available',
48 'committed',
49 'on_hold',
50 'fulfillment_type',
51 'item_status',
52 'manage_cost',
53 'item_price',
54 'item_cost',
55 'compare_price',
56 'other_info',
57 'downloadable',
58 'shipping_class'
59 ];
60
61
62 /**
63 * The attributes that should be cast.
64 *
65 * @var array
66 */
67 protected $casts = [
68 'post_id' => 'integer',
69 'media_id' => 'integer',
70 'item_cost' => 'double',
71 'item_price' => 'double',
72 'compare_price' => 'double',
73 'backorders' => 'integer',
74 'total_stock' => 'integer',
75 'available' => 'integer',
76 'committed' => 'integer',
77 'on_hold' => 'integer',
78 'sold_individually' => 'integer',
79 'serial_index' => 'integer',
80 'other_info' => 'array',
81 ];
82
83 protected $appends = ['thumbnail'];
84
85
86 public function getOtherInfoAttribute($value)
87 {
88 $value = !empty($value) ? json_decode($value, true) : [];
89
90 if($this->payment_type === 'subscription'){
91 $isInstallment = (Arr::get($value, 'installment', 'no') === 'yes' && App::isProActive())?
92 'yes':'no';
93 $value['payment_type'] = 'subscription';
94 $value['installment'] = $isInstallment;
95 $value['repeat_interval'] = Arr::get($value, 'repeat_interval', 'yearly');
96 $value['times'] = Arr::get($value, 'times', 0);
97 $value['trial_days'] = Arr::get($value, 'trial_days', 0);
98 $value['manage_setup_fee'] = Arr::get($value, 'manage_setup_fee', 'no');
99 }
100 return $value;
101 }
102
103
104
105 protected static function booted()
106 {
107 static::retrieved(function ($product) {
108 $product->append('formatted_total');
109 });
110 }
111
112 protected function getFormattedTotalAttribute()
113 {
114 return Helper::toDecimal($this->item_price);
115 }
116
117
118 /**
119 * One2One: Product Variation belongs to one Product
120 *
121 * @return \FluentCart\Framework\Database\Orm\Relations\BelongsTo
122 */
123 public function product(): \FluentCart\Framework\Database\Orm\Relations\BelongsTo
124 {
125 return $this->belongsTo(Product::class, 'post_id', 'ID');
126 }
127
128 public function shippingClass(): \FluentCart\Framework\Database\Orm\Relations\BelongsTo
129 {
130 return $this->belongsTo(ShippingClass::class, 'shipping_class', 'id');
131 }
132
133 /**
134 * One2One: Product Variation belongs to one Product detail
135 *
136 * @return \FluentCart\Framework\Database\Orm\Relations\BelongsTo
137 */
138 public function product_detail()
139 {
140 return $this->belongsTo(ProductDetail::class, 'post_id', 'post_id');
141 }
142
143 public function media()
144 {
145 return $this->hasOne(ProductMeta::class, 'object_id', 'id')->select('id', 'object_id', 'meta_value')->where('meta_key', 'product_thumbnail');
146 }
147
148 public function product_downloads(): \FluentCart\Framework\Database\Orm\Relations\HasMany
149 {
150 return $this
151 ->hasMany(ProductDownload::class, 'post_id', 'post_id')
152 ->where('product_variation_id', 'like', '%' . $this->id . '%')
153 ->orWhereNull('product_variation_id')
154 ->orWhere('product_variation_id', '[]');
155
156 }
157
158 public function order_items()
159 {
160 return $this->hasMany(OrderItem::class, 'object_id', 'id');
161 }
162
163 public function downloadable_files()
164 {
165 return $this->hasMany(ProductDownload::class, 'product_variation_id', 'id');
166 }
167
168 public function upgrade_paths(): \FluentCart\Framework\Database\Orm\Relations\HasMany
169 {
170 return $this->hasMany(Meta::class, 'object_id', 'id')
171 ->where('object_type', PlanUpgradeService::$metaType)
172 ->where('meta_key', PlanUpgradeService::$metaKey);
173 }
174
175 public function attrMap()
176 {
177 return $this->hasMany(AttributeRelation::class, 'object_id', 'id');
178 }
179
180 public function getThumbnailAttribute()
181 {
182 if (empty($this->media) || !is_array($this->media->meta_value)) {
183 return null;
184 }
185 // Ensure the first element exists and has a non-empty 'url' key
186 if (empty($this->media->meta_value[0]['url'])) {
187 return null;
188 }
189
190 return $this->media->meta_value[0]['url'];
191 }
192
193 public function scopeGetWithShippingClass(Builder $query)
194 {
195 $variations = $query->get();
196
197 $shippingMethodIds = $variations->pluck('other_info.shipping_class')->filter(function ($item) {
198 return !empty($item);
199 })->toArray();
200
201
202 $shippingMethods = ShippingMethod::query()->whereIn('id', $shippingMethodIds)->get()->keyBy('id');
203
204 $variations->map(function ($variation) use ($shippingMethods) {
205 $shippingClassId = Arr::get($variation, 'other_info.shipping_class');
206 if (!$shippingClassId) {
207 return $variation;
208 }
209 $method = $shippingMethods->get($shippingClassId);
210
211 if (!$method) {
212 return $variation;
213 }
214 $variation->attributes['shipping_method'] = $method;
215 return $variation;
216 });
217
218
219 return $query->get();
220 }
221
222
223 public static function boot()
224 {
225 parent::boot();
226 static::deleting(function ($model) {
227 \FluentCart\Api\Meta::deleteVariationMedia($model->id);
228 $model->attrMap()->delete();
229 });
230 }
231
232 /**
233 * Check if the product variation can be purchased
234 *
235 * @param int $quantity
236 * @return bool|\WP_Error
237 */
238 public function canPurchase($quantity = 1)
239 {
240 if ($this->item_status !== 'active' || !in_array($this->product->post_status, ['publish', 'private'])) {
241 return new \WP_Error('unpublished', __('This product is not available for purchase.', 'fluent-cart'));
242 }
243
244 if ($this->payment_type === 'subscription' && $quantity > 1) {
245 return new \WP_Error('invalid_subscription_quantity', __('You cannot purchase more than one subscription at a time.', 'fluent-cart'));
246 }
247
248 $productDetail = $this->product_detail;
249 if (!$productDetail) {
250 return new \WP_Error('unpublished', __('This product is not available for purchase', 'fluent-cart'));
251 }
252
253 if (ModuleSettings::isActive('stock_management')) {
254 if (($productDetail->manage_stock && $this->manage_stock) && $quantity > $this->available) {
255 return new \WP_Error('insufficient_stock', __('Sorry, this product is currently out of stock.', 'fluent-cart'));
256 }
257 }
258
259
260 if ($this->product->isBundleProduct() && !App::isProActive()) {
261 return new \WP_Error('invalid_bundle_product', __('Sorry, this product is not available for purchase.', 'fluent-cart'));
262
263 }
264
265 $bundleCheck = apply_filters('fluent_cart/variation/can_purchase_bundle', null, [
266 'variation' => $this,
267 'quantity' => (int)$quantity
268 ]);
269 if (is_wp_error($bundleCheck)) {
270 return $bundleCheck;
271 } elseif ($bundleCheck === false) {
272 return new \WP_Error('insufficient_stock', __('Sorry, this product is currently out of stock.', 'fluent-cart'));
273 }
274
275 return true;
276 }
277
278 public function getSubscriptionTermsText($withComparePrice = false)
279 {
280 if ($this->payment_type !== 'subscription') {
281 return '';
282 }
283
284 $otherInfo = $this->other_info;
285
286 $formattedData = [
287 'trial_days' => Arr::get($otherInfo, 'trial_days', 0),
288 'interval' => Arr::get($otherInfo, 'repeat_interval', 'yearly'),
289 'times' => Arr::get($otherInfo, 'times', 0), // 0 means infinite
290 'signup_fee' => Arr::get($otherInfo, 'signup_fee', 0) ? Helper::toDecimal(Arr::get($otherInfo, 'signup_fee', 0)) : 0,
291 'signup_fee_label' => Arr::get($otherInfo, 'signup_fee_name', ''),
292 'price' => Helper::toDecimal($this->item_price),
293 'compare_price' => ($withComparePrice && $this->compare_price > $this->item_price) ? Helper::toDecimal($this->compare_price) : 0,
294 ];
295
296 return Helper::getSubscriptionTermText($formattedData);
297 }
298
299 public function getPurchaseUrl()
300 {
301 return site_url('?fluent-cart=instant_checkout&item_id=' . $this->id . '&quantity=1');
302 }
303
304 public function soldIndividually()
305 {
306 if ($this->product) {
307 return $this->product->soldIndividually();
308 }
309 return false;
310 }
311
312 public function isStock(): bool
313 {
314 // Check if variation is active
315 if ($this->item_status !== 'active') {
316 return false;
317 }
318
319 // Check if this is a bundle product variation
320 $isBundleProduct = $this->product && $this->product->isBundleProduct();
321
322 // If stock management is disabled for this variation
323 if (!$this->manage_stock) {
324 // For bundle products, still check child items
325 if ($isBundleProduct) {
326 return $this->isBundleChildrenInStock();
327 }
328 // For regular products without stock management, check status
329 return $this->stock_status === Helper::IN_STOCK;
330 }
331
332 // Stock management is enabled - check availability and status
333 $hasStock = ($this->available > 0 && $this->stock_status === Helper::IN_STOCK);
334
335 // For non-bundle products, return stock status
336 if (!$isBundleProduct) {
337 return $hasStock;
338 }
339
340 // For bundle products, parent must be in stock AND all children must be in stock
341 if (!$hasStock) {
342 return false;
343 }
344
345 return $this->isBundleChildrenInStock();
346 }
347
348 /**
349 * Check if all bundle children are in stock
350 *
351 * @return bool
352 */
353 protected function isBundleChildrenInStock(): bool
354 {
355 $childIds = Arr::get($this->other_info, 'bundle_child_ids', []);
356
357 // No bundle children, consider as in stock
358 if (empty($childIds) || !is_array($childIds)) {
359 return true;
360 }
361
362 // Get all bundle children variations
363 $children = static::query()
364 ->whereIn('id', $childIds)
365 ->get(['id', 'manage_stock', 'available', 'stock_status', 'item_status', 'post_id', 'other_info']);
366
367 // Check each child
368 foreach ($children as $child) {
369 // Child must be active
370 if ($child->item_status !== 'active') {
371 return false;
372 }
373
374 // Only check stock for children that manage inventory.
375 // Children without stock management (e.g. digital products) are always considered in stock.
376 if ((int)$child->manage_stock === 1) {
377 if ((int)$child->available <= 0 || $child->stock_status !== Helper::IN_STOCK) {
378 return false;
379 }
380 }
381
382 // Nested bundle check commented out: bundle_child_ids live on the bundle's own variation,
383 // not on the leaf child variation referenced here. Each bundle's isStock() handles its own
384 // children independently, so this recursive call is redundant and causes N+1 queries.
385 // if ($child->product && $child->product->isBundleProduct()) {
386 // $nestedChildIds = Arr::get($child->other_info, 'bundle_child_ids', []);
387 // if (!empty($nestedChildIds)) {
388 // if (!$child->isBundleChildrenInStock()) {
389 // return false;
390 // }
391 // }
392 // }
393 }
394
395 return true;
396 }
397
398 public function bundleChildren(): BundleChildrenRelation
399 {
400 return new BundleChildrenRelation(
401 $this->newQuery(),
402 $this,
403 'other_info', // JSON column name
404 'bundle_child_ids' // JSON key name
405 );
406 }
407 }
408