PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.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 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Models / ProductVariation.php

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

413 lines 13.1 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 attrRelations(): \FluentCart\Framework\Database\Orm\Relations\HasMany
159 {
160 return $this->hasMany(AttributeRelation::class, 'object_id', 'id');
161 }
162
163 public function order_items()
164 {
165 return $this->hasMany(OrderItem::class, 'object_id', 'id');
166 }
167
168 public function downloadable_files()
169 {
170 return $this->hasMany(ProductDownload::class, 'product_variation_id', 'id');
171 }
172
173 public function upgrade_paths(): \FluentCart\Framework\Database\Orm\Relations\HasMany
174 {
175 return $this->hasMany(Meta::class, 'object_id', 'id')
176 ->where('object_type', PlanUpgradeService::$metaType)
177 ->where('meta_key', PlanUpgradeService::$metaKey);
178 }
179
180 public function attrMap()
181 {
182 return $this->hasMany(AttributeRelation::class, 'object_id', 'id');
183 }
184
185 public function getThumbnailAttribute()
186 {
187 if (empty($this->media) || !is_array($this->media->meta_value)) {
188 return null;
189 }
190 // Ensure the first element exists and has a non-empty 'url' key
191 if (empty($this->media->meta_value[0]['url'])) {
192 return null;
193 }
194
195 return $this->media->meta_value[0]['url'];
196 }
197
198 public function scopeGetWithShippingClass(Builder $query)
199 {
200 $variations = $query->get();
201
202 $shippingMethodIds = $variations->pluck('other_info.shipping_class')->filter(function ($item) {
203 return !empty($item);
204 })->toArray();
205
206
207 $shippingMethods = ShippingMethod::query()->whereIn('id', $shippingMethodIds)->get()->keyBy('id');
208
209 $variations->map(function ($variation) use ($shippingMethods) {
210 $shippingClassId = Arr::get($variation, 'other_info.shipping_class');
211 if (!$shippingClassId) {
212 return $variation;
213 }
214 $method = $shippingMethods->get($shippingClassId);
215
216 if (!$method) {
217 return $variation;
218 }
219 $variation->attributes['shipping_method'] = $method;
220 return $variation;
221 });
222
223
224 return $query->get();
225 }
226
227
228 public static function boot()
229 {
230 parent::boot();
231 static::deleting(function ($model) {
232 \FluentCart\Api\Meta::deleteVariationMedia($model->id);
233 $model->attrMap()->delete();
234 });
235 }
236
237 /**
238 * Check if the product variation can be purchased
239 *
240 * @param int $quantity
241 * @return bool|\WP_Error
242 */
243 public function canPurchase($quantity = 1)
244 {
245 if ($this->item_status !== 'active' || !in_array($this->product->post_status, ['publish', 'private'])) {
246 return new \WP_Error('unpublished', __('This product is not available for purchase.', 'fluent-cart'));
247 }
248
249 if ($this->payment_type === 'subscription' && $quantity > 1) {
250 return new \WP_Error('invalid_subscription_quantity', __('You cannot purchase more than one subscription at a time.', 'fluent-cart'));
251 }
252
253 $productDetail = $this->product_detail;
254 if (!$productDetail) {
255 return new \WP_Error('unpublished', __('This product is not available for purchase', 'fluent-cart'));
256 }
257
258 if (ModuleSettings::isActive('stock_management')) {
259 if (($productDetail->manage_stock && $this->manage_stock) && $quantity > $this->available) {
260 return new \WP_Error('insufficient_stock', __('Sorry, this product is currently out of stock.', 'fluent-cart'));
261 }
262 }
263
264
265 if ($this->product->isBundleProduct() && !App::isProActive()) {
266 return new \WP_Error('invalid_bundle_product', __('Sorry, this product is not available for purchase.', 'fluent-cart'));
267
268 }
269
270 $bundleCheck = apply_filters('fluent_cart/variation/can_purchase_bundle', null, [
271 'variation' => $this,
272 'quantity' => (int)$quantity
273 ]);
274 if (is_wp_error($bundleCheck)) {
275 return $bundleCheck;
276 } elseif ($bundleCheck === false) {
277 return new \WP_Error('insufficient_stock', __('Sorry, this product is currently out of stock.', 'fluent-cart'));
278 }
279
280 return true;
281 }
282
283 public function getSubscriptionTermsText($withComparePrice = false)
284 {
285 if ($this->payment_type !== 'subscription') {
286 return '';
287 }
288
289 $otherInfo = $this->other_info;
290
291 $formattedData = [
292 'trial_days' => Arr::get($otherInfo, 'trial_days', 0),
293 'interval' => Arr::get($otherInfo, 'repeat_interval', 'yearly'),
294 'times' => Arr::get($otherInfo, 'times', 0), // 0 means infinite
295 'signup_fee' => Arr::get($otherInfo, 'signup_fee', 0) ? Helper::toDecimal(Arr::get($otherInfo, 'signup_fee', 0)) : 0,
296 'signup_fee_label' => Arr::get($otherInfo, 'signup_fee_name', ''),
297 'price' => Helper::toDecimal($this->item_price),
298 'compare_price' => ($withComparePrice && $this->compare_price > $this->item_price) ? Helper::toDecimal($this->compare_price) : 0,
299 ];
300
301 return Helper::getSubscriptionTermText($formattedData);
302 }
303
304 public function getPurchaseUrl()
305 {
306 return site_url('?fluent-cart=instant_checkout&item_id=' . $this->id . '&quantity=1');
307 }
308
309 public function soldIndividually()
310 {
311 if ($this->product) {
312 return $this->product->soldIndividually();
313 }
314 return false;
315 }
316
317 public function isStock(): bool
318 {
319 // Check if variation is active
320 if ($this->item_status !== 'active') {
321 return false;
322 }
323
324 // Check if this is a bundle product variation
325 $isBundleProduct = $this->product && $this->product->isBundleProduct();
326
327 // If stock management is disabled for this variation
328 if (!$this->manage_stock) {
329 // For bundle products, still check child items
330 if ($isBundleProduct) {
331 return $this->isBundleChildrenInStock();
332 }
333 // For regular products without stock management, check status
334 return $this->stock_status === Helper::IN_STOCK;
335 }
336
337 // Stock management is enabled - check availability and status
338 $hasStock = ($this->available > 0 && $this->stock_status === Helper::IN_STOCK);
339
340 // For non-bundle products, return stock status
341 if (!$isBundleProduct) {
342 return $hasStock;
343 }
344
345 // For bundle products, parent must be in stock AND all children must be in stock
346 if (!$hasStock) {
347 return false;
348 }
349
350 return $this->isBundleChildrenInStock();
351 }
352
353 /**
354 * Check if all bundle children are in stock
355 *
356 * @return bool
357 */
358 protected function isBundleChildrenInStock(): bool
359 {
360 $childIds = Arr::get($this->other_info, 'bundle_child_ids', []);
361
362 // No bundle children, consider as in stock
363 if (empty($childIds) || !is_array($childIds)) {
364 return true;
365 }
366
367 // Get all bundle children variations
368 $children = static::query()
369 ->whereIn('id', $childIds)
370 ->get(['id', 'manage_stock', 'available', 'stock_status', 'item_status', 'post_id', 'other_info']);
371
372 // Check each child
373 foreach ($children as $child) {
374 // Child must be active
375 if ($child->item_status !== 'active') {
376 return false;
377 }
378
379 // Only check stock for children that manage inventory.
380 // Children without stock management (e.g. digital products) are always considered in stock.
381 if ((int)$child->manage_stock === 1) {
382 if ((int)$child->available <= 0 || $child->stock_status !== Helper::IN_STOCK) {
383 return false;
384 }
385 }
386
387 // Nested bundle check commented out: bundle_child_ids live on the bundle's own variation,
388 // not on the leaf child variation referenced here. Each bundle's isStock() handles its own
389 // children independently, so this recursive call is redundant and causes N+1 queries.
390 // if ($child->product && $child->product->isBundleProduct()) {
391 // $nestedChildIds = Arr::get($child->other_info, 'bundle_child_ids', []);
392 // if (!empty($nestedChildIds)) {
393 // if (!$child->isBundleChildrenInStock()) {
394 // return false;
395 // }
396 // }
397 // }
398 }
399
400 return true;
401 }
402
403 public function bundleChildren(): BundleChildrenRelation
404 {
405 return new BundleChildrenRelation(
406 $this->newQuery(),
407 $this,
408 'other_info', // JSON column name
409 'bundle_child_ids' // JSON key name
410 );
411 }
412 }
413