laravel-shop/src/Models/Product.php

998 lines
32 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable;
use Blax\Workkit\Traits\HasMetaTranslation;
use Blax\Shop\Events\ProductCreated;
use Blax\Shop\Events\ProductUpdated;
use Blax\Shop\Contracts\Purchasable;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Exceptions\HasNoDefaultPriceException;
use Blax\Shop\Exceptions\HasNoPriceException;
use Blax\Shop\Exceptions\InvalidBookingConfigurationException;
use Blax\Shop\Exceptions\InvalidPoolConfigurationException;
use Blax\Shop\Services\CartService;
use Blax\Shop\Traits\ChecksIfBooking;
use Blax\Shop\Traits\HasCategories;
use Blax\Shop\Traits\HasPrices;
use Blax\Shop\Traits\HasPricingStrategy;
use Blax\Shop\Traits\HasProductRelations;
use Blax\Shop\Traits\HasStocks;
use Blax\Shop\Traits\MayBePoolProduct;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Cache;
class Product extends Model implements Purchasable, Cartable
{
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasPricingStrategy, HasCategories, HasProductRelations, MayBePoolProduct, ChecksIfBooking;
protected $fillable = [
'slug',
'sku',
'type',
'stripe_product_id',
'sale_start',
'sale_end',
'manage_stock',
'low_stock_threshold',
'weight',
'length',
'width',
'height',
'virtual',
'downloadable',
'parent_id',
'featured',
'is_visible',
'status',
'published_at',
'meta',
'tax_class',
'sort_order',
'name',
'description',
'short_description',
];
protected $casts = [
'manage_stock' => 'boolean',
'virtual' => 'boolean',
'downloadable' => 'boolean',
'type' => ProductType::class,
'status' => ProductStatus::class,
'meta' => 'object',
'sale_start' => 'datetime',
'sale_end' => 'datetime',
'published_at' => 'datetime',
'featured' => 'boolean',
'is_visible' => 'boolean',
'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);
}
});
static::deleted(function ($model) {
$model->actions()->delete();
$model->attributes()->delete();
});
}
public function parent()
{
return $this->belongsTo(static::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(static::class, 'parent_id');
}
public function attributes(): HasMany
{
return $this->hasMany(config('shop.models.product_attribute', 'Blax\Shop\Models\ProductAttribute'));
}
public function actions(): HasMany
{
return $this->hasMany(config('shop.models.product_action', ProductAction::class));
}
public function purchases(): MorphMany
{
return $this->morphMany(
config('shop.models.product_purchase', ProductPurchase::class),
'purchasable'
);
}
public function scopePublished($query)
{
return $query->where('status', ProductStatus::PUBLISHED->value);
}
public function scopeFeatured($query)
{
return $query->where('featured', true);
}
public function isOnSale(): bool
{
if (!$this->sale_start) {
return false;
}
$now = now();
if ($now->lt($this->sale_start)) {
return false;
}
if ($this->sale_end && $now->gt($this->sale_end)) {
return false;
}
return true;
}
/**
* Duplicate/clone this product with all related data.
*
* Creates a copy of the product including:
* - All basic attributes (with modified slug/sku)
* - All prices
* - All categories
* - All product attributes
* - All product relations (related, upsell, cross-sell)
* - All children (variants) if includeChildren is true
*
* @param array $overrides Attributes to override in the duplicated product
* @param bool $includeChildren Whether to duplicate child products (variants)
* @param bool $includePrices Whether to duplicate prices
* @param bool $includeCategories Whether to duplicate category associations
* @param bool $includeAttributes Whether to duplicate product attributes
* @param bool $includeRelations Whether to duplicate product relations
* @return static The duplicated product
*/
public function duplicate(
array $overrides = [],
bool $includeChildren = true,
bool $includePrices = true,
bool $includeCategories = true,
bool $includeAttributes = true,
bool $includeRelations = true
): static {
// Get attributes to duplicate
$attributes = $this->attributesToArray();
// Remove fields that shouldn't be copied
unset(
$attributes['id'],
$attributes['created_at'],
$attributes['updated_at'],
$attributes['deleted_at'],
$attributes['stripe_product_id'], // Stripe ID should be unique
);
// Generate unique slug and SKU
$baseSlug = preg_replace('/-copy(-\d+)?$/', '', $this->slug);
// Get all existing slugs with this base in one query
$existingSlugs = static::where('slug', 'LIKE', $baseSlug . '-copy%')
->orWhere('slug', $baseSlug . '-copy')
->pluck('slug')
->flip()
->toArray();
$suffix = '-copy';
$counter = 1;
while (isset($existingSlugs[$baseSlug . $suffix])) {
$suffix = '-copy-' . ++$counter;
}
$attributes['slug'] = $baseSlug . $suffix;
// Handle SKU uniqueness
if ($this->sku) {
$baseSku = preg_replace('/-COPY(-\d+)?$/i', '', $this->sku);
// Get all existing SKUs with this base in one query
$existingSkus = static::where('sku', 'LIKE', $baseSku . '-COPY%')
->orWhere('sku', $baseSku . '-COPY')
->pluck('sku')
->map(fn($s) => strtoupper($s))
->flip()
->toArray();
$skuSuffix = '-COPY';
$skuCounter = 1;
while (isset($existingSkus[strtoupper($baseSku . $skuSuffix)])) {
$skuSuffix = '-COPY-' . ++$skuCounter;
}
$attributes['sku'] = $baseSku . $skuSuffix;
}
// Set as draft by default
$attributes['status'] = ProductStatus::DRAFT->value;
$attributes['published_at'] = null;
// Apply overrides
$attributes = array_merge($attributes, $overrides);
// Create the duplicate product
$duplicate = static::create($attributes);
// Duplicate prices
if ($includePrices && method_exists($this, 'prices')) {
foreach ($this->prices as $price) {
$priceData = $price->attributesToArray();
unset(
$priceData['id'],
$priceData['purchasable_id'],
$priceData['purchasable_type'],
$priceData['stripe_price_id'],
$priceData['created_at'],
$priceData['updated_at']
);
$duplicate->prices()->create($priceData);
}
}
// Duplicate categories
if ($includeCategories && method_exists($this, 'categories')) {
$categoryIds = $this->categories->pluck('id')->toArray();
if (!empty($categoryIds)) {
$duplicate->categories()->sync($categoryIds);
}
}
// Duplicate attributes (product attributes, not model attributes)
if ($includeAttributes) {
foreach ($this->attributes()->get() as $attribute) {
$attrData = $attribute->attributesToArray();
unset(
$attrData['id'],
$attrData['product_id'],
$attrData['created_at'],
$attrData['updated_at']
);
$duplicate->attributes()->create($attrData);
}
}
// Duplicate product relations
if ($includeRelations && method_exists($this, 'relatedProducts')) {
foreach ($this->relatedProducts as $related) {
$duplicate->relatedProducts()->attach($related->id, [
'type' => $related->pivot->type ?? 'related',
'sort_order' => $related->pivot->sort_order ?? 0,
]);
}
}
// Duplicate children (variants)
if ($includeChildren) {
foreach ($this->children as $child) {
$child->duplicate(
['parent_id' => $duplicate->id],
false, // Don't recurse into children's children
$includePrices,
$includeCategories,
$includeAttributes,
$includeRelations
);
}
}
return $duplicate->fresh();
}
public static function getAvailableActions(): array
{
return ProductAction::getAvailableActions();
}
public function callActions(string $event = 'purchased', ?ProductPurchase $productPurchase = null, array $additionalData = [])
{
return ProductAction::callForProduct(
$this,
$event,
$productPurchase,
$additionalData
);
}
public function scopeVisible($query)
{
return $query->where('is_visible', true)
->where('status', ProductStatus::PUBLISHED->value)
->where(function ($q) {
$q->whereNull('published_at')
->orWhere('published_at', '<=', now());
});
}
public function scopeSearch($query, string $search)
{
return $query->where(function ($q) use ($search) {
$q->where('slug', 'like', "%{$search}%")
->orWhere('sku', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
});
}
public function isVisible(): bool
{
if (!$this->is_visible || $this->status !== ProductStatus::PUBLISHED) {
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);
}
/**
* Check if this is a booking product
*/
public function isBooking(): bool
{
return $this->checkProductIsBooking($this);
}
/**
* Check stock availability for a booking period
*/
public function isAvailableForBooking(\DateTimeInterface $from, \DateTimeInterface $until, int $quantity = 1): bool
{
// For pool products, delegate to pool-specific availability checking
if ($this->isPool()) {
$available = $this->getPoolMaxQuantity($from, $until);
return $available === PHP_INT_MAX || $available >= $quantity;
}
if (!$this->manage_stock) {
return true;
}
// Get stock claims (CLAIMED entries) that overlap with the requested period
$overlappingClaims = $this->stocks()
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
->where(function ($query) use ($from, $until) {
$query->where(function ($q) use ($from, $until) {
// Claim starts during the requested period (exclusive end for hotel-style bookings)
$q->where('claimed_from', '>=', $from)
->where('claimed_from', '<', $until);
})->orWhere(function ($q) use ($from, $until) {
// Claim ends during the requested period (exclusive start - checkout day = checkin day is OK)
$q->where('expires_at', '>', $from)
->where('expires_at', '<=', $until);
})->orWhere(function ($q) use ($from, $until) {
// Claim encompasses the entire requested period
$q->where('claimed_from', '<=', $from)
->where('expires_at', '>', $until);
})->orWhere(function ($q) use ($from, $until) {
// Claim without claimed_from (immediately claimed)
$q->whereNull('claimed_from')
->where(function ($subQ) use ($from, $until) {
$subQ->whereNull('expires_at')
->orWhere('expires_at', '>', $from);
});
});
})
->sum('quantity');
// Also get DECREASE entries with expires_at that overlap (from completed bookings)
// These are booking purchases that reduce stock during the booking period
$overlappingBookings = $this->stocks()
->where('type', StockType::DECREASE->value)
->where('status', StockStatus::COMPLETED->value)
->whereNotNull('expires_at')
->where('expires_at', '>', $from) // Booking hasn't ended before our period starts
->sum('quantity');
// Use base stock at the START of the booking period and subtract all overlapping reservations
// We check availability at $from because claims that expire before then should not affect availability
// Note: overlappingBookings is already negative (DECREASE entries), so we add it
$availableStock = $this->getAvailableStock($from) - abs($overlappingClaims) + $overlappingBookings;
return $availableStock >= $quantity;
}
/**
* Scope for booking products
*/
public function scopeBookings($query)
{
return $query->where('type', ProductType::BOOKING->value);
}
/**
* Get the current price with pool product inheritance support and cart-aware pricing.
*
* IMPORTANT: This method handles cart-aware pricing automatically!
*
* For pool products, this method:
* - Automatically retrieves the cart from session or authenticated user if not provided
* - Considers which price tiers are already used in the cart
* - Returns the next available price based on the pricing strategy (LOWEST, HIGHEST, AVERAGE)
*
* ⚠️ COMMON MISTAKE: Do NOT call getNextAvailablePoolPriceConsideringCart() directly!
* Always use this method instead, as it handles cart resolution and edge cases properly.
*
* Example usage:
* ```php
* ✅ CORRECT: Let getCurrentPrice handle cart resolution
* $price = $product->getCurrentPrice();
*
* ✅ CORRECT: Pass cart explicitly if you have it
* $price = $product->getCurrentPrice(null, $cart);
*
* ✅ CORRECT: Pass dates for booking calculations
* $price = $product->getCurrentPrice(null, $cart, $fromDate, $untilDate);
*
* ❌ WRONG: Bypasses cart resolution and session handling
* $price = $product->getNextAvailablePoolPriceConsideringCart($cart, null);
* ```
*
* @param bool|null $sales_price Whether to get sale price (null = auto-detect)
* @param mixed $cart Optional cart instance (auto-resolved from session/user if not provided)
* @param \DateTimeInterface|null $from Optional start date for booking calculations
* @param \DateTimeInterface|null $until Optional end date for booking calculations
* @param string|int|null $excludeCartItemId Cart item ID to exclude from usage calculation (for date updates)
* @return float|null The current price, or null if unavailable
*/
public function getCurrentPrice(
bool|null $sales_price = null,
mixed $cart = null,
?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null,
string|int|null $excludeCartItemId = null
): ?float {
// If this is a pool product, use cart-aware pricing if cart is provided
if ($this->isPool()) {
// If no cart provided, try to get the cart from session first, then user's cart
if (!$cart) {
// Try session first
$cartId = session(CartService::CART_SESSION_KEY);
if ($cartId) {
$cart = \Blax\Shop\Models\Cart::find($cartId);
// Make sure the cart is valid (not expired/converted)
if ($cart && ($cart->isExpired() || $cart->isConverted())) {
$cart = null;
}
}
// Fall back to authenticated user's cart if no valid session cart
if (!$cart && auth()->check()) {
$cart = auth()->user()->currentCart();
}
}
if ($cart) {
// Cart-aware: Use smarter pricing that considers which price tiers are used
// This returns null if no items are available (all sold out)
return $this->getNextAvailablePoolPriceConsideringCart($cart, $sales_price, $from, $until, $excludeCartItemId);
}
// No cart: Get inherited price from single items
// This returns null if no items are available OR if items exist but have no prices
return $this->getInheritedPoolPrice($sales_price);
}
// For non-pool products, use the trait's default behavior
return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
}
/**
* Validate booking product configuration and provide helpful error messages
*
* @throws InvalidBookingConfigurationException
*/
public function validateBookingConfiguration(bool $throwOnWarnings = false): array
{
$errors = [];
$warnings = [];
if (!$this->isBooking()) {
throw InvalidBookingConfigurationException::notABookingProduct($this->name);
}
// Critical: Stock management must be enabled
if (!$this->manage_stock) {
throw InvalidBookingConfigurationException::stockManagementNotEnabled($this->name);
}
// Check for available stock
if ($this->getAvailableStock() <= 0) {
$warnings[] = "No stock available for booking";
if ($throwOnWarnings) {
throw InvalidBookingConfigurationException::noStockAvailable($this->name);
}
}
// Check for pricing
if (!$this->hasPrice()) {
$warnings[] = "No pricing configured";
if ($throwOnWarnings) {
throw InvalidBookingConfigurationException::noPricingConfigured($this->name);
}
}
return [
'valid' => empty($errors),
'errors' => $errors,
'warnings' => $warnings,
];
}
/**
* Validate product pricing configuration
*
* @throws HasNoPriceException
* @throws HasNoDefaultPriceException
*/
public function validatePricing(bool $throwExceptions = true): array
{
$errors = [];
$warnings = [];
// Special handling for pool products
if ($this->isPool()) {
// Use exists() for efficiency - avoids loading all prices just to check
$hasDirectPrice = $this->relationLoaded('prices')
? $this->prices->isNotEmpty()
: $this->prices()->exists();
if (!$hasDirectPrice) {
// Check if single items have prices to inherit
// Use withCount for efficiency if not already loaded
$singleItems = $this->relationLoaded('singleProducts')
? $this->singleProducts
: $this->singleProducts()->get();
$singleItemsWithPrices = $singleItems->filter(function ($item) {
return $item->relationLoaded('prices')
? $item->prices->isNotEmpty()
: $item->prices()->exists();
});
if ($singleItemsWithPrices->isEmpty()) {
// Pool has no direct price AND no single items with prices
// This is only an error if we're actually trying to use the price
// So we don't throw here - let the actual usage point handle it
$warnings[] = "Pool product has no pricing (direct or inherited). Price will be needed when adding to cart.";
} else {
// Pool has single items with prices - this is valid
$warnings[] = "Pool product uses inherited pricing from single items";
}
}
// If pool has direct prices, validate them
if ($hasDirectPrice) {
return $this->validateDirectPricing($throwExceptions);
}
// Pool without direct pricing is valid as long as it has single items with prices
return [
'valid' => empty($errors),
'errors' => $errors,
'warnings' => $warnings,
];
}
// For all other product types, validate direct pricing
return $this->validateDirectPricing($throwExceptions);
}
/**
* Validate direct pricing on the product
*
* @throws HasNoPriceException
* @throws HasNoDefaultPriceException
*/
protected function validateDirectPricing(bool $throwExceptions = true): array
{
$errors = [];
$warnings = [];
$allPrices = $this->prices;
$priceCount = $allPrices->count();
// No prices at all
if ($priceCount === 0) {
$errors[] = "Product has no prices configured";
if ($throwExceptions) {
throw HasNoPriceException::noPricesConfigured($this->name, $this->id);
}
return [
'valid' => false,
'errors' => $errors,
'warnings' => $warnings,
];
}
$defaultPrices = $allPrices->where('is_default', true);
$defaultCount = $defaultPrices->count();
// Multiple default prices
if ($defaultCount > 1) {
$errors[] = "Product has {$defaultCount} default prices (should have exactly 1)";
if ($throwExceptions) {
throw HasNoDefaultPriceException::multipleDefaultPrices($this->name, $defaultCount);
}
return [
'valid' => false,
'errors' => $errors,
'warnings' => $warnings,
];
}
// No default price
if ($defaultCount === 0) {
if ($priceCount === 1) {
// Single price but not marked as default
$errors[] = "Product has one price but it's not marked as default";
if ($throwExceptions) {
throw HasNoDefaultPriceException::onlyNonDefaultPriceExists($this->name);
}
} else {
// Multiple prices but none are default
$errors[] = "Product has {$priceCount} prices but none are marked as default";
if ($throwExceptions) {
throw HasNoDefaultPriceException::multiplePricesNoDefault($this->name, $priceCount);
}
}
return [
'valid' => false,
'errors' => $errors,
'warnings' => $warnings,
];
}
// Valid: Exactly one default price
return [
'valid' => true,
'errors' => $errors,
'warnings' => $warnings,
];
}
/**
* Get helpful setup instructions for pool products
*/
public static function getPoolSetupInstructions(): string
{
return <<<'INSTRUCTIONS'
# Pool Product Setup Guide
Pool products aggregate multiple individual items (e.g., parking spots, hotel rooms)
into a single purchasable product where customers don't need to select specific items.
## Step 1: Create the Pool Product
```php
use Blax\Shop\Models\Product;
use Blax\Shop\Enums\ProductType;
$pool = Product::create([
'type' => ProductType::POOL,
'name' => 'Parking Lot',
'slug' => 'parking-lot',
]);
```
## Step 2: Create Single Items (Booking Products)
```php
$spot1 = Product::create([
'type' => ProductType::BOOKING,
'name' => 'Parking Spot #1',
'manage_stock' => true,
]);
$spot1->increaseStock(1);
$spot2 = Product::create([
'type' => ProductType::BOOKING,
'name' => 'Parking Spot #2',
'manage_stock' => true,
]);
$spot2->increaseStock(1);
```
## Step 3: Link Single Items to Pool
```php
use Blax\Shop\Enums\ProductRelationType;
$pool->productRelations()->attach([
$spot1->id => ['type' => ProductRelationType::SINGLE],
$spot2->id => ['type' => ProductRelationType::SINGLE],
]);
```
## Step 4: Set Pricing (Optional)
```php
use Blax\Shop\Models\ProductPrice;
// Option A: Set price on pool (takes precedence)
ProductPrice::create([
'purchasable_id' => $pool->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000, // 50.00 per day
'currency' => 'USD',
'is_default' => true,
]);
// Option B: Set prices on single items (pool inherits)
ProductPrice::create([
'purchasable_id' => $spot1->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
// Set pricing strategy (if using inheritance)
$pool->setPoolPricingStrategy('average'); // or 'lowest' or 'highest'
```
## Step 5: Add to Cart with Timespan
```php
use Blax\Shop\Facades\Cart;
use Carbon\Carbon;
Cart::addBooking(
$pool,
2, // quantity
Carbon::parse('2025-01-15 09:00'), // from
Carbon::parse('2025-01-17 17:00'), // until
);
```
## Validation
```php
// Validate configuration before use
$validation = $pool->validatePoolConfiguration();
if (!$validation['valid']) {
foreach ($validation['errors'] as $error) {
echo "Error: $error\n";
}
}
```
INSTRUCTIONS;
}
/**
* Get helpful setup instructions for booking products
*/
public static function getBookingSetupInstructions(): string
{
return <<<'INSTRUCTIONS'
# Booking Product Setup Guide
Booking products represent time-based reservations (conference rooms, equipment, etc.)
## Step 1: Create the Booking Product
```php
use Blax\Shop\Models\Product;
use Blax\Shop\Enums\ProductType;
$product = Product::create([
'type' => ProductType::BOOKING,
'name' => 'Conference Room A',
'manage_stock' => true, // REQUIRED for bookings
]);
```
## Step 2: Set Initial Stock
```php
// For single-unit bookings (1 room, 1 equipment piece, etc.)
$product->increaseStock(1);
// For multiple units (e.g., 5 identical meeting rooms)
$product->increaseStock(5);
```
## Step 3: Configure Pricing
```php
use Blax\Shop\Models\ProductPrice;
ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 10000, // Price per day in cents (100.00 USD)
'currency' => 'USD',
'is_default' => true,
]);
```
## Step 4: Add to Cart with Timespan
```php
use Blax\Shop\Facades\Cart;
use Carbon\Carbon;
Cart::addBooking(
$product,
1, // quantity
Carbon::parse('2025-01-15 09:00'), // from
Carbon::parse('2025-01-17 17:00'), // until
);
// Price will be: 100.00/day × 3 days = 300.00
```
## Check Availability
```php
$from = Carbon::parse('2025-01-15 09:00');
$until = Carbon::parse('2025-01-17 17:00');
if ($product->isAvailableForBooking($from, $until, 1)) {
// Product is available for this period
Cart::addBooking($product, 1, $from, $until);
}
```
## Validation
```php
// Validate configuration
$validation = $product->validateBookingConfiguration();
if (!$validation['valid']) {
foreach ($validation['errors'] as $error) {
echo "Error: $error\n";
}
}
```
INSTRUCTIONS;
}
}