'integer', 'media_id' => 'integer', 'item_cost' => 'double', 'item_price' => 'double', 'compare_price' => 'double', 'backorders' => 'integer', 'total_stock' => 'integer', 'available' => 'integer', 'committed' => 'integer', 'on_hold' => 'integer', 'sold_individually' => 'integer', 'serial_index' => 'integer', 'other_info' => 'array', ]; protected $appends = ['thumbnail']; public function getOtherInfoAttribute($value) { $value = !empty($value) ? json_decode($value, true) : []; if($this->payment_type === 'subscription'){ $isInstallment = (Arr::get($value, 'installment', 'no') === 'yes' && App::isProActive())? 'yes':'no'; $value['payment_type'] = 'subscription'; $value['installment'] = $isInstallment; $value['repeat_interval'] = Arr::get($value, 'repeat_interval', 'yearly'); $value['times'] = Arr::get($value, 'times', 0); $value['trial_days'] = Arr::get($value, 'trial_days', 0); $value['manage_setup_fee'] = Arr::get($value, 'manage_setup_fee', 'no'); } return $value; } protected static function booted() { static::retrieved(function ($product) { $product->append('formatted_total'); }); } protected function getFormattedTotalAttribute() { return Helper::toDecimal($this->item_price); } /** * One2One: Product Variation belongs to one Product * * @return \FluentCart\Framework\Database\Orm\Relations\BelongsTo */ public function product(): \FluentCart\Framework\Database\Orm\Relations\BelongsTo { return $this->belongsTo(Product::class, 'post_id', 'ID'); } public function shippingClass(): \FluentCart\Framework\Database\Orm\Relations\BelongsTo { return $this->belongsTo(ShippingClass::class, 'shipping_class', 'id'); } /** * Labeled attribute combination for this variation, e.g. "Color: Red | Size: XS". * * Uses the batched `variation_display_title` when * AttributeHelper::attachVariationDisplayTitles() has already resolved it, * and otherwise resolves on demand — so callers get a correct value either * way, and a correctly batched caller costs no extra queries. * * @return string Falls back to the stored variation title. */ public function getVariationLabel(): string { $resolved = (string) ($this->variation_display_title ?? ''); if ($resolved !== '') { return $resolved; } $variationType = $this->product_detail ? $this->product_detail->variation_type : ''; $label = AttributeHelper::getDisplayAttributesString( AttributeHelper::getProductItemAttributes($this->id, $this->post_id), [ 'title' => $this->variation_title, 'variation_type' => $variationType, 'other_info' => ['variation_type' => $variationType], ], 'order_item' ); return $label !== '' ? $label : (string) $this->variation_title; } /** * Full display title for this variation: " - ". * * The catalogue-side counterpart to OrderItem::getDisplayTitle(), which * composes the same shape from a frozen line item. Product feeds, plan * names and CLI output all need the product name alongside the variation, * and each was concatenating it themselves. * * @return string */ public function getDisplayTitle(): string { $label = $this->getVariationLabel(); $productTitle = $this->product ? (string) $this->product->post_title : ''; if ($productTitle === '' || $productTitle === $label) { return $label; } if ($label === '') { return $productTitle; } return $productTitle . ' - ' . $label; } /** * One2One: Product Variation belongs to one Product detail * * @return \FluentCart\Framework\Database\Orm\Relations\BelongsTo */ public function product_detail() { return $this->belongsTo(ProductDetail::class, 'post_id', 'post_id'); } public function media() { return $this->hasOne(ProductMeta::class, 'object_id', 'id')->select('id', 'object_id', 'meta_value')->where('meta_key', 'product_thumbnail'); } public function product_downloads(): \FluentCart\Framework\Database\Orm\Relations\HasMany { return $this ->hasMany(ProductDownload::class, 'post_id', 'post_id') ->where('product_variation_id', 'like', '%' . $this->id . '%') ->orWhereNull('product_variation_id') ->orWhere('product_variation_id', '[]'); } public function attrRelations(): \FluentCart\Framework\Database\Orm\Relations\HasMany { return $this->hasMany(AttributeRelation::class, 'object_id', 'id'); } public function order_items() { return $this->hasMany(OrderItem::class, 'object_id', 'id'); } public function downloadable_files() { return $this->hasMany(ProductDownload::class, 'product_variation_id', 'id'); } public function upgrade_paths(): \FluentCart\Framework\Database\Orm\Relations\HasMany { return $this->hasMany(Meta::class, 'object_id', 'id') ->where('object_type', PlanUpgradeService::$metaType) ->where('meta_key', PlanUpgradeService::$metaKey); } public function attrMap() { return $this->hasMany(AttributeRelation::class, 'object_id', 'id'); } public function getThumbnailAttribute() { if (empty($this->media) || !is_array($this->media->meta_value)) { return null; } // Ensure the first element exists and has a non-empty 'url' key if (empty($this->media->meta_value[0]['url'])) { return null; } return $this->media->meta_value[0]['url']; } public function scopeGetWithShippingClass(Builder $query) { $variations = $query->get(); $shippingMethodIds = $variations->pluck('other_info.shipping_class')->filter(function ($item) { return !empty($item); })->toArray(); $shippingMethods = ShippingMethod::query()->whereIn('id', $shippingMethodIds)->get()->keyBy('id'); $variations->map(function ($variation) use ($shippingMethods) { $shippingClassId = Arr::get($variation, 'other_info.shipping_class'); if (!$shippingClassId) { return $variation; } $method = $shippingMethods->get($shippingClassId); if (!$method) { return $variation; } $variation->attributes['shipping_method'] = $method; return $variation; }); return $query->get(); } public static function boot() { parent::boot(); static::deleting(function ($model) { \FluentCart\Api\Meta::deleteVariationMedia($model->id); $model->attrMap()->delete(); }); } /** * Check if the product variation can be purchased * * @param int $quantity * @return bool|\WP_Error */ public function canPurchase($quantity = 1) { if ($this->item_status !== 'active' || !in_array($this->product->post_status, ['publish', 'private'])) { return new \WP_Error('unpublished', __('This product is not available for purchase.', 'fluent-cart')); } if ($this->payment_type === 'subscription' && $quantity > 1) { return new \WP_Error('invalid_subscription_quantity', __('You cannot purchase more than one subscription at a time.', 'fluent-cart')); } $productDetail = $this->product_detail; if (!$productDetail) { return new \WP_Error('unpublished', __('This product is not available for purchase', 'fluent-cart')); } if (ModuleSettings::isActive('stock_management')) { if (($productDetail->manage_stock && $this->manage_stock) && $quantity > $this->available) { return new \WP_Error('insufficient_stock', __('Sorry, this product is currently out of stock.', 'fluent-cart')); } } if ($this->product->isBundleProduct() && !App::isProActive()) { return new \WP_Error('invalid_bundle_product', __('Sorry, this product is not available for purchase.', 'fluent-cart')); } $bundleCheck = apply_filters('fluent_cart/variation/can_purchase_bundle', null, [ 'variation' => $this, 'quantity' => (int)$quantity ]); if (is_wp_error($bundleCheck)) { return $bundleCheck; } elseif ($bundleCheck === false) { return new \WP_Error('insufficient_stock', __('Sorry, this product is currently out of stock.', 'fluent-cart')); } return true; } public function getSubscriptionTermsText($withComparePrice = false) { if ($this->payment_type !== 'subscription') { return ''; } $otherInfo = $this->other_info; $formattedData = [ 'trial_days' => Arr::get($otherInfo, 'trial_days', 0), 'interval' => Arr::get($otherInfo, 'repeat_interval', 'yearly'), 'times' => Arr::get($otherInfo, 'times', 0), // 0 means infinite 'signup_fee' => Arr::get($otherInfo, 'signup_fee', 0) ? Helper::toDecimal(Arr::get($otherInfo, 'signup_fee', 0)) : 0, 'signup_fee_label' => Arr::get($otherInfo, 'signup_fee_name', ''), 'price' => Helper::toDecimal($this->item_price), 'compare_price' => ($withComparePrice && $this->compare_price > $this->item_price) ? Helper::toDecimal($this->compare_price) : 0, ]; return Helper::getSubscriptionTermText($formattedData); } public function getPurchaseUrl() { return site_url('?fluent-cart=instant_checkout&item_id=' . $this->id . '&quantity=1'); } public function soldIndividually() { if ($this->product) { return $this->product->soldIndividually(); } return false; } public function isStock(): bool { // Check if variation is active if ($this->item_status !== 'active') { return false; } // Check if this is a bundle product variation $isBundleProduct = $this->product && $this->product->isBundleProduct(); // If stock management is disabled for this variation if (!$this->manage_stock) { // For bundle products, still check child items if ($isBundleProduct) { return $this->isBundleChildrenInStock(); } // For regular products without stock management, check status return $this->stock_status === Helper::IN_STOCK; } // Stock management is enabled - check availability and status $hasStock = ($this->available > 0 && $this->stock_status === Helper::IN_STOCK); // For non-bundle products, return stock status if (!$isBundleProduct) { return $hasStock; } // For bundle products, parent must be in stock AND all children must be in stock if (!$hasStock) { return false; } return $this->isBundleChildrenInStock(); } /** * Check if all bundle children are in stock * * @return bool */ protected function isBundleChildrenInStock(): bool { $childIds = Arr::get($this->other_info, 'bundle_child_ids', []); // No bundle children, consider as in stock if (empty($childIds) || !is_array($childIds)) { return true; } // Get all bundle children variations $children = static::query() ->whereIn('id', $childIds) ->get(['id', 'manage_stock', 'available', 'stock_status', 'item_status', 'post_id', 'other_info']); // Check each child foreach ($children as $child) { // Child must be active if ($child->item_status !== 'active') { return false; } // Only check stock for children that manage inventory. // Children without stock management (e.g. digital products) are always considered in stock. if ((int)$child->manage_stock === 1) { if ((int)$child->available <= 0 || $child->stock_status !== Helper::IN_STOCK) { return false; } } // Nested bundle check commented out: bundle_child_ids live on the bundle's own variation, // not on the leaf child variation referenced here. Each bundle's isStock() handles its own // children independently, so this recursive call is redundant and causes N+1 queries. // if ($child->product && $child->product->isBundleProduct()) { // $nestedChildIds = Arr::get($child->other_info, 'bundle_child_ids', []); // if (!empty($nestedChildIds)) { // if (!$child->isBundleChildrenInStock()) { // return false; // } // } // } } return true; } public function bundleChildren(): BundleChildrenRelation { return new BundleChildrenRelation( $this->newQuery(), $this, 'other_info', // JSON column name 'bundle_child_ids' // JSON key name ); } }