2025-11-21 10:49:41 +00:00
< ? php
namespace Blax\Shop\Models ;
use App\Services\StripeService ;
2025-11-23 14:07:12 +00:00
use Blax\Shop\Contracts\Cartable ;
2025-11-21 10:49:41 +00:00
use Blax\Workkit\Traits\HasMetaTranslation ;
use Blax\Shop\Events\ProductCreated ;
use Blax\Shop\Events\ProductUpdated ;
use Blax\Shop\Contracts\Purchasable ;
2025-11-23 14:07:12 +00:00
use Blax\Shop\Exceptions\NotEnoughStockException ;
2025-11-21 10:49:41 +00:00
use Illuminate\Database\Eloquent\Concerns\HasUuids ;
use Illuminate\Database\Eloquent\Factories\HasFactory ;
use Illuminate\Database\Eloquent\Model ;
use Illuminate\Database\Eloquent\Relations\BelongsToMany ;
use Illuminate\Database\Eloquent\Relations\HasMany ;
2025-11-23 14:07:12 +00:00
use Illuminate\Database\Eloquent\Relations\MorphMany ;
2025-11-21 10:49:41 +00:00
use Illuminate\Support\Facades\Cache ;
2025-11-22 14:13:30 +00:00
use Illuminate\Support\Facades\DB ;
2025-11-21 10:49:41 +00:00
2025-11-23 14:07:12 +00:00
class Product extends Model implements Purchasable , Cartable
2025-11-21 10:49:41 +00:00
{
use HasFactory , HasUuids , HasMetaTranslation ;
protected $fillable = [
'slug' ,
2025-11-25 16:25:20 +00:00
'sku' ,
2025-11-21 10:49:41 +00:00
'type' ,
'stripe_product_id' ,
'sale_start' ,
'sale_end' ,
'manage_stock' ,
'low_stock_threshold' ,
'weight' ,
'length' ,
'width' ,
'height' ,
'virtual' ,
'downloadable' ,
'parent_id' ,
'featured' ,
2025-11-22 08:55:58 +00:00
'is_visible' ,
2025-11-21 10:49:41 +00:00
'status' ,
'published_at' ,
'meta' ,
'tax_class' ,
'sort_order' ,
];
protected $casts = [
'manage_stock' => 'boolean' ,
'virtual' => 'boolean' ,
'downloadable' => 'boolean' ,
'meta' => 'object' ,
'sale_start' => 'datetime' ,
'sale_end' => 'datetime' ,
'published_at' => 'datetime' ,
'featured' => 'boolean' ,
2025-11-22 08:55:58 +00:00
'is_visible' => 'boolean' ,
2025-11-21 10:49:41 +00:00
'low_stock_threshold' => 'integer' ,
'sort_order' => 'integer' ,
];
protected $dispatchesEvents = [
'created' => ProductCreated :: class ,
'updated' => ProductUpdated :: class ,
];
protected $hidden = [
'stripe_product_id' ,
];
public function __construct ( array $attributes = [])
{
// Initialize meta BEFORE parent constructor to avoid trait errors
if ( ! isset ( $attributes [ 'meta' ])) {
$attributes [ 'meta' ] = '{}' ;
}
parent :: __construct ( $attributes );
$this -> setTable ( config ( 'shop.tables.products' , 'products' ));
}
/**
* Initialize the HasMetaTranslation trait for the model .
*
* @ return void
*/
protected function initializeHasMetaTranslation ()
{
// Ensure meta is never null
if ( ! isset ( $this -> attributes [ 'meta' ])) {
$this -> attributes [ 'meta' ] = '{}' ;
}
}
protected static function booted ()
{
parent :: booted ();
static :: creating ( function ( $model ) {
if ( ! $model -> slug ) {
$model -> slug = 'new-product-' . str () -> random ( 8 );
}
$model -> slug = str () -> slug ( $model -> slug );
// Ensure meta is initialized before creation
if ( is_null ( $model -> getAttributes ()[ 'meta' ] ? ? null )) {
$model -> setAttribute ( 'meta' , json_encode ( new \stdClass ()));
}
});
static :: updated ( function ( $model ) {
if ( config ( 'shop.cache.enabled' )) {
Cache :: forget ( config ( 'shop.cache.prefix' ) . 'product:' . $model -> id );
}
});
2025-11-22 17:09:45 +00:00
static :: deleted ( function ( $model ) {
$model -> actions () -> delete ();
2025-11-25 16:14:00 +00:00
$model -> attributes () -> delete ();
2025-11-22 17:09:45 +00:00
});
2025-11-21 10:49:41 +00:00
}
2025-11-23 14:07:12 +00:00
public function prices () : MorphMany
2025-11-21 10:49:41 +00:00
{
2025-11-23 14:07:12 +00:00
return $this -> morphMany (
config ( 'shop.models.product_price' , ProductPrice :: class ),
'purchasable'
);
2025-11-21 10:49:41 +00:00
}
public function parent ()
{
return $this -> belongsTo ( self :: class , 'parent_id' );
}
public function children () : HasMany
{
return $this -> hasMany ( self :: class , 'parent_id' );
}
public function categories () : BelongsToMany
{
return $this -> belongsToMany (
config ( 'shop.models.product_category' ),
'product_category_product'
);
}
public function attributes () : HasMany
{
return $this -> hasMany ( config ( 'shop.models.product_attribute' , 'Blax\Shop\Models\ProductAttribute' ));
}
public function stocks () : HasMany
{
return $this -> hasMany ( config ( 'shop.models.product_stock' , 'Blax\Shop\Models\ProductStock' ));
}
public function actions () : HasMany
{
return $this -> hasMany ( config ( 'shop.models.product_action' , ProductAction :: class ));
}
2025-11-23 14:07:12 +00:00
public function getAvailableStocksAttribute () : int
{
2025-11-25 11:33:42 +00:00
return $this -> stocks ()
-> available ()
-> where ( function ( $query ) {
$query -> whereNull ( 'expires_at' )
-> orWhere ( 'expires_at' , '>' , now ());
})
-> sum ( 'quantity' ) ? ? 0 ;
2025-11-23 14:07:12 +00:00
}
public function purchases () : MorphMany
2025-11-21 10:49:41 +00:00
{
2025-11-23 14:07:12 +00:00
return $this -> morphMany (
config ( 'shop.models.product_purchase' , ProductPurchase :: class ),
'purchasable'
);
2025-11-21 10:49:41 +00:00
}
public function scopePublished ( $query )
{
return $query -> where ( 'status' , 'published' );
}
public function scopeFeatured ( $query )
{
return $query -> where ( 'featured' , true );
}
public function isOnSale () : bool
{
2025-11-24 13:32:11 +00:00
if ( ! $this -> sale_start ) {
return false ;
}
2025-11-21 10:49:41 +00:00
$now = now ();
2025-11-24 13:32:11 +00:00
if ( $now -> lt ( $this -> sale_start )) {
2025-11-21 10:49:41 +00:00
return false ;
}
if ( $this -> sale_end && $now -> gt ( $this -> sale_end )) {
return false ;
}
return true ;
}
2025-11-28 09:24:07 +00:00
public function getCurrentPrice ( bool | null $sales_price = null ) : ? float
2025-11-21 10:49:41 +00:00
{
2025-11-28 09:24:07 +00:00
return $this -> defaultPrice () -> first () ? -> getCurrentPrice ( $sales_price ? ? $this -> isOnSale ());
2025-11-21 10:49:41 +00:00
}
2025-11-23 14:07:12 +00:00
public function isInStock () : bool
{
if ( ! $this -> manage_stock ) {
return true ;
}
return $this -> getAvailableStock () > 0 ;
}
2025-11-21 10:49:41 +00:00
public function decreaseStock ( int $quantity = 1 ) : bool
{
if ( ! $this -> manage_stock ) {
return true ;
}
2025-11-23 14:07:12 +00:00
if ( $this -> AvailableStocks < $quantity ) {
return throw new NotEnoughStockException ( " Not enough stock available for product ID { $this -> id } " );
2025-11-21 10:49:41 +00:00
}
2025-11-23 14:07:12 +00:00
$this -> stocks () -> create ([
'quantity' => - $quantity ,
'type' => 'decrease' ,
'status' => 'completed' ,
]);
$this -> logStockChange ( - $quantity , 'decrease' );
2025-11-21 10:49:41 +00:00
$this -> save ();
return true ;
}
2025-11-23 14:07:12 +00:00
public function increaseStock ( int $quantity = 1 ) : bool
2025-11-21 10:49:41 +00:00
{
if ( ! $this -> manage_stock ) {
2025-11-23 14:07:12 +00:00
return false ;
2025-11-21 10:49:41 +00:00
}
2025-11-23 14:07:12 +00:00
$this -> stocks () -> create ([
'quantity' => $quantity ,
'type' => 'increase' ,
'status' => 'completed' ,
]);
2025-11-22 17:09:45 +00:00
$this -> logStockChange ( $quantity , 'increase' );
2025-11-21 10:49:41 +00:00
$this -> save ();
2025-11-23 14:07:12 +00:00
return true ;
2025-11-21 10:49:41 +00:00
}
public function reserveStock (
int $quantity ,
$reference = null ,
? \DateTimeInterface $until = null ,
? string $note = null
) : ? \Blax\Shop\Models\ProductStock {
2025-11-25 11:33:42 +00:00
if ( ! $this -> manage_stock ) {
return null ;
}
2025-11-21 10:49:41 +00:00
$stockModel = config ( 'shop.models.product_stock' , 'Blax\Shop\Models\ProductStock' );
return $stockModel :: reserve (
$this ,
$quantity ,
$reference ,
$until ,
$note
);
}
public function getAvailableStock () : int
{
if ( ! $this -> manage_stock ) {
return PHP_INT_MAX ;
}
2025-11-23 14:07:12 +00:00
return max ( 0 , $this -> AvailableStocks );
2025-11-21 10:49:41 +00:00
}
public function getReservedStock () : int
{
return $this -> activeStocks () -> sum ( 'quantity' );
}
protected function logStockChange ( int $quantityChange , string $type ) : void
{
2025-11-22 14:13:30 +00:00
DB :: table ( 'product_stock_logs' ) -> insert ([
2025-11-21 10:49:41 +00:00
'product_id' => $this -> id ,
'quantity_change' => $quantityChange ,
'quantity_after' => $this -> stock_quantity ,
'type' => $type ,
'created_at' => now (),
'updated_at' => now (),
]);
}
public static function getAvailableActions () : array
{
return ProductAction :: getAvailableActions ();
}
2025-11-29 19:09:19 +00:00
public function callActions ( string $event = 'purchased' , ? ProductPurchase $productPurchase = null , array $additionalData = [])
2025-11-21 10:49:41 +00:00
{
2025-11-29 19:09:19 +00:00
return ProductAction :: callForProduct (
$this ,
$event ,
$productPurchase ,
$additionalData
);
2025-11-21 10:49:41 +00:00
}
public function relatedProducts () : BelongsToMany
{
return $this -> belongsToMany (
self :: class ,
'product_relations' ,
'product_id' ,
'related_product_id'
) -> withPivot ( 'type' ) -> withTimestamps ();
}
public function upsells () : BelongsToMany
{
return $this -> relatedProducts () -> wherePivot ( 'type' , 'upsell' );
}
public function crossSells () : BelongsToMany
{
return $this -> relatedProducts () -> wherePivot ( 'type' , 'cross-sell' );
}
2025-11-23 14:07:12 +00:00
public function scopeInStock ( $query )
{
return $query -> where ( function ( $q ) {
$q -> where ( 'manage_stock' , false )
-> orWhere ( function ( $q2 ) {
$q2 -> where ( 'manage_stock' , true )
-> whereRaw ( " (SELECT SUM(quantity) FROM " . config ( 'shop.tables.product_stocks' , 'product_stocks' ) . " WHERE product_id = " . config ( 'shop.tables.products' , 'products' ) . " .id) > 0 " );
});
});
}
2025-11-21 10:49:41 +00:00
public function scopeVisible ( $query )
{
2025-11-22 08:55:58 +00:00
return $query -> where ( 'is_visible' , true )
2025-11-21 10:49:41 +00:00
-> where ( 'status' , 'published' )
-> where ( function ( $q ) {
$q -> whereNull ( 'published_at' )
-> orWhere ( 'published_at' , '<=' , now ());
});
}
public function scopeByCategory ( $query , $categoryId )
{
return $query -> whereHas ( 'categories' , function ( $q ) use ( $categoryId ) {
$q -> where ( 'id' , $categoryId );
});
}
public function scopeSearch ( $query , string $search )
{
return $query -> where ( function ( $q ) use ( $search ) {
$q -> where ( 'slug' , 'like' , " % { $search } % " )
-> orWhere ( 'sku' , 'like' , " % { $search } % " )
2025-11-25 16:14:00 +00:00
-> orWhere ( 'name' , 'like' , " % { $search } % " );
2025-11-21 10:49:41 +00:00
});
}
public function scopePriceRange ( $query , ? float $min = null , ? float $max = null )
{
if ( $min !== null ) {
$query -> where ( 'price' , '>=' , $min );
}
if ( $max !== null ) {
$query -> where ( 'price' , '<=' , $max );
}
return $query ;
}
public function scopeOrderByPrice ( $query , string $direction = 'asc' )
{
return $query -> orderBy ( 'price' , $direction );
}
public function scopeLowStock ( $query )
{
2025-11-25 16:14:00 +00:00
$stockTable = config ( 'shop.tables.product_stocks' , 'product_stocks' );
$productTable = config ( 'shop.tables.products' , 'products' );
2025-11-29 19:09:19 +00:00
2025-11-21 10:49:41 +00:00
return $query -> where ( 'manage_stock' , true )
2025-11-25 16:14:00 +00:00
-> whereNotNull ( 'low_stock_threshold' )
-> whereRaw ( " ( \n SELECT COALESCE(SUM(quantity), 0) \n FROM { $stockTable } \n WHERE { $stockTable } .product_id = { $productTable } .id \n AND { $stockTable } .status IN ('completed', 'pending') \n AND ( { $stockTable } .expires_at IS NULL OR { $stockTable } .expires_at > ?) \n ) <= { $productTable } .low_stock_threshold " , [ now ()]);
2025-11-21 10:49:41 +00:00
}
public function isLowStock () : bool
{
if ( ! $this -> manage_stock || ! $this -> low_stock_threshold ) {
return false ;
}
2025-11-25 16:02:39 +00:00
return $this -> getAvailableStock () <= $this -> low_stock_threshold ;
2025-11-21 10:49:41 +00:00
}
public function isVisible () : bool
{
2025-11-22 08:55:58 +00:00
if ( ! $this -> is_visible || $this -> status !== 'published' ) {
2025-11-21 10:49:41 +00:00
return false ;
}
if ( $this -> published_at && now () -> lt ( $this -> published_at )) {
return false ;
}
return true ;
}
public function toApiArray () : array
{
return [
'id' => $this -> id ,
'slug' => $this -> slug ,
'sku' => $this -> sku ,
'name' => $this -> getLocalized ( 'name' ),
'description' => $this -> getLocalized ( 'description' ),
'short_description' => $this -> getLocalized ( 'short_description' ),
'type' => $this -> type ,
'price' => $this -> getCurrentPrice (),
'sale_price' => $this -> sale_price ,
'is_on_sale' => $this -> isOnSale (),
'low_stock' => $this -> isLowStock (),
'featured' => $this -> featured ,
'virtual' => $this -> virtual ,
'downloadable' => $this -> downloadable ,
'weight' => $this -> weight ,
'dimensions' => [
'length' => $this -> length ,
'width' => $this -> width ,
'height' => $this -> height ,
],
'categories' => $this -> categories ,
'attributes' => $this -> attributes ,
'variants' => $this -> children ,
'parent' => $this -> parent ,
'created_at' => $this -> created_at ,
'updated_at' => $this -> updated_at ,
];
}
/**
* Get an attribute from the model .
*
* @ param string $key
* @ return mixed
*/
public function getAttribute ( $key )
{
$value = parent :: getAttribute ( $key );
// Ensure meta is never null for HasMetaTranslation trait
if ( $key === 'meta' && is_null ( $value )) {
$this -> attributes [ 'meta' ] = '{}' ;
return json_decode ( '{}' );
}
return $value ;
}
/**
* Create a new instance of the given model .
*
* @ param array $attributes
* @ param bool $exists
* @ return static
*/
public function newInstance ( $attributes = [], $exists = false )
{
// Ensure meta is initialized
if ( ! isset ( $attributes [ 'meta' ])) {
$attributes [ 'meta' ] = '{}' ;
}
return parent :: newInstance ( $attributes , $exists );
}
2025-11-23 14:07:12 +00:00
public function defaultPrice ()
{
return $this -> prices () -> where ( 'is_default' , true );
}
2025-11-24 06:00:07 +00:00
public function getPriceAttribute () : ? float
{
return $this -> getCurrentPrice ();
}
2025-11-25 11:33:42 +00:00
public function reservations ()
{
$stockModel = config ( 'shop.models.product_stock' , 'Blax\Shop\Models\ProductStock' );
return $stockModel :: reservations ()
-> where ( function ( $query ) {
$query -> whereNull ( 'expires_at' )
-> orWhere ( 'expires_at' , '>' , now ());
})
-> where ( 'product_id' , $this -> id );
}
2025-11-29 07:32:58 +00:00
2025-11-29 19:09:19 +00:00
public function hasPrice () : bool
2025-11-29 07:32:58 +00:00
{
return $this -> prices () -> exists ();
}
2025-11-21 10:49:41 +00:00
}