2025-11-21 10:49:41 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Blax\Shop\Models;
|
|
|
|
|
|
2025-12-17 11:26:26 +00:00
|
|
|
use Blax\Shop\Exceptions\InvalidDateRangeException;
|
2025-12-18 15:54:33 +00:00
|
|
|
use Blax\Shop\Traits\ChecksIfBooking;
|
2025-12-17 16:57:17 +00:00
|
|
|
use Blax\Shop\Traits\HasBookingPriceCalculation;
|
2025-11-23 14:07:12 +00:00
|
|
|
use Blax\Workkit\Traits\HasMeta;
|
2025-11-21 10:49:41 +00:00
|
|
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
|
|
|
|
|
|
|
|
class CartItem extends Model
|
|
|
|
|
{
|
2025-12-18 15:54:33 +00:00
|
|
|
use HasUuids, HasMeta, HasBookingPriceCalculation, ChecksIfBooking;
|
2025-11-21 10:49:41 +00:00
|
|
|
|
|
|
|
|
protected $fillable = [
|
|
|
|
|
'cart_id',
|
2025-11-23 14:07:12 +00:00
|
|
|
'purchasable_id',
|
|
|
|
|
'purchasable_type',
|
2025-12-30 08:29:43 +00:00
|
|
|
'product_id',
|
2025-12-17 11:26:26 +00:00
|
|
|
'price_id',
|
2025-11-21 10:49:41 +00:00
|
|
|
'quantity',
|
|
|
|
|
'price',
|
|
|
|
|
'regular_price',
|
2025-12-18 11:21:29 +00:00
|
|
|
'unit_amount',
|
2025-11-21 10:49:41 +00:00
|
|
|
'subtotal',
|
2025-11-23 14:07:12 +00:00
|
|
|
'parameters',
|
2025-11-28 09:24:07 +00:00
|
|
|
'purchase_id',
|
2025-11-21 10:49:41 +00:00
|
|
|
'meta',
|
2025-12-15 10:32:31 +00:00
|
|
|
'from',
|
|
|
|
|
'until',
|
2025-11-21 10:49:41 +00:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
protected $casts = [
|
|
|
|
|
'quantity' => 'integer',
|
2025-12-18 09:54:42 +00:00
|
|
|
'price' => 'integer',
|
|
|
|
|
'regular_price' => 'integer',
|
2025-12-18 11:21:29 +00:00
|
|
|
'unit_amount' => 'integer',
|
2025-12-18 09:54:42 +00:00
|
|
|
'subtotal' => 'integer',
|
2025-11-23 14:07:12 +00:00
|
|
|
'parameters' => 'array',
|
2025-11-21 10:49:41 +00:00
|
|
|
'meta' => 'array',
|
2025-12-17 16:13:08 +00:00
|
|
|
'from' => 'datetime',
|
|
|
|
|
'until' => 'datetime',
|
2025-11-21 10:49:41 +00:00
|
|
|
];
|
|
|
|
|
|
2025-12-17 11:26:26 +00:00
|
|
|
protected $appends = [
|
|
|
|
|
'is_booking',
|
|
|
|
|
'is_ready_to_checkout',
|
|
|
|
|
];
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
public function __construct(array $attributes = [])
|
|
|
|
|
{
|
|
|
|
|
parent::__construct($attributes);
|
|
|
|
|
$this->table = config('shop.tables.cart_items', 'cart_items');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected static function boot()
|
|
|
|
|
{
|
|
|
|
|
parent::boot();
|
|
|
|
|
|
|
|
|
|
// Auto-calculate subtotal before saving
|
|
|
|
|
static::creating(function ($cartItem) {
|
|
|
|
|
if (!isset($cartItem->subtotal)) {
|
|
|
|
|
$cartItem->subtotal = $cartItem->quantity * $cartItem->price;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
static::updating(function ($cartItem) {
|
|
|
|
|
if ($cartItem->isDirty(['quantity', 'price'])) {
|
|
|
|
|
$cartItem->subtotal = $cartItem->quantity * $cartItem->price;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function cart(): BelongsTo
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsTo(config('shop.models.cart'), 'cart_id');
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 11:26:26 +00:00
|
|
|
public function price(): BelongsTo
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsTo(config('shop.models.product_price', ProductPrice::class), 'price_id');
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-23 14:07:12 +00:00
|
|
|
public function purchasable()
|
2025-11-21 10:49:41 +00:00
|
|
|
{
|
2025-11-23 14:07:12 +00:00
|
|
|
return $this->morphTo('purchasable');
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-28 09:24:07 +00:00
|
|
|
public function purchase()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasOne(
|
|
|
|
|
config('shop.models.product_purchase', ProductPurchase::class),
|
|
|
|
|
'id',
|
|
|
|
|
'purchase_id'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 08:29:43 +00:00
|
|
|
/**
|
|
|
|
|
* Get the actual product being purchased.
|
|
|
|
|
* For pool products, this is the single item allocated.
|
|
|
|
|
* For regular products, this returns the purchasable product itself.
|
|
|
|
|
*/
|
|
|
|
|
public function product(): BelongsTo
|
2025-11-23 14:07:12 +00:00
|
|
|
{
|
2025-12-30 08:29:43 +00:00
|
|
|
return $this->belongsTo(config('shop.models.product', Product::class), 'product_id');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the effective product - either the allocated product_id or the purchasable.
|
|
|
|
|
* This is useful for getting the actual product when product_id may be null.
|
|
|
|
|
*/
|
|
|
|
|
public function getEffectiveProduct(): ?Product
|
|
|
|
|
{
|
|
|
|
|
if ($this->product_id) {
|
|
|
|
|
return $this->product;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($this->purchasable instanceof Product) {
|
|
|
|
|
return $this->purchasable;
|
2025-11-23 14:07:12 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getSubtotal(): float
|
|
|
|
|
{
|
|
|
|
|
return $this->quantity * $this->price;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 11:19:56 +00:00
|
|
|
/**
|
|
|
|
|
* Get display subtotal - returns null for booking items not ready for checkout
|
|
|
|
|
* Use this for display purposes when you want null for incomplete bookings
|
|
|
|
|
*
|
|
|
|
|
* @return int|null
|
|
|
|
|
*/
|
|
|
|
|
public function getDisplaySubtotalAttribute(): ?int
|
|
|
|
|
{
|
|
|
|
|
if (!$this->is_ready_to_checkout && $this->is_booking) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return $this->attributes['subtotal'] ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get display price - returns null for booking items not ready for checkout
|
|
|
|
|
* Use this for display purposes when you want null for incomplete bookings
|
|
|
|
|
*
|
|
|
|
|
* @return int|null
|
|
|
|
|
*/
|
|
|
|
|
public function getDisplayPriceAttribute(): ?int
|
|
|
|
|
{
|
|
|
|
|
if (!$this->is_ready_to_checkout && $this->is_booking) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return $this->attributes['price'] ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
public function scopeForCart($query, $cartId)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('cart_id', $cartId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function scopeForProduct($query, $productId)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('product_id', $productId);
|
|
|
|
|
}
|
2025-12-15 11:28:15 +00:00
|
|
|
|
2025-12-17 11:26:26 +00:00
|
|
|
/**
|
|
|
|
|
* Check if this cart item is for a booking product
|
|
|
|
|
*/
|
|
|
|
|
public function getIsBookingAttribute(): bool
|
|
|
|
|
{
|
|
|
|
|
if (!$this->price_id) {
|
|
|
|
|
// Fallback: check purchasable directly if no price_id
|
|
|
|
|
if ($this->purchasable_type === config('shop.models.product', Product::class)) {
|
|
|
|
|
$product = $this->purchasable;
|
2025-12-18 15:54:33 +00:00
|
|
|
return $product && $this->checkProductIsBooking($product);
|
2025-12-17 11:26:26 +00:00
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use the relationship method, not property access
|
|
|
|
|
$price = $this->price()->first();
|
|
|
|
|
if (!$price) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$product = $price->purchasable;
|
|
|
|
|
if (!$product || !($product instanceof Product)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 15:54:33 +00:00
|
|
|
return $this->checkProductIsBooking($product);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this cart item is for a booking product (method alias)
|
|
|
|
|
*/
|
|
|
|
|
public function isBooking(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->is_booking;
|
2025-12-17 11:26:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this cart item is ready for checkout.
|
|
|
|
|
* Uses effective dates (item's own dates or cart's dates as fallback).
|
|
|
|
|
*
|
|
|
|
|
* Returns true if:
|
|
|
|
|
* - For booking products: has valid dates and stock is available
|
|
|
|
|
* - For pool products with booking items: has valid dates and stock is available
|
|
|
|
|
* - For other products: stock is available
|
|
|
|
|
*
|
|
|
|
|
* @return bool
|
|
|
|
|
*/
|
|
|
|
|
public function getIsReadyToCheckoutAttribute(): bool
|
|
|
|
|
{
|
2025-12-26 07:42:59 +00:00
|
|
|
$product = $this->purchasable instanceof ProductPrice
|
|
|
|
|
? $this->purchasable->purchasable
|
|
|
|
|
: $this->purchasable;
|
2025-12-17 11:26:26 +00:00
|
|
|
|
|
|
|
|
if (!$product) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 07:42:59 +00:00
|
|
|
// Check if item has a valid price
|
|
|
|
|
if ($this->price === null) {
|
2025-12-20 11:19:34 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Note: Pool items don't require pre-allocation to be ready for checkout.
|
|
|
|
|
// The checkout process can allocate singles on-the-fly via claimPoolStock().
|
|
|
|
|
// The price check above is sufficient - if price is null, item is unavailable.
|
2025-12-26 07:42:59 +00:00
|
|
|
$is_booking = $product->isBooking();
|
|
|
|
|
$is_pool = $product->isPool();
|
2025-12-20 11:19:34 +00:00
|
|
|
|
2025-12-17 11:26:26 +00:00
|
|
|
// Check if dates are required (for booking products or pools with booking items)
|
2025-12-26 07:42:59 +00:00
|
|
|
$requiresDates = $is_booking ||
|
|
|
|
|
($is_pool && $product->hasBookingSingleItems());
|
2025-12-17 11:26:26 +00:00
|
|
|
|
|
|
|
|
if ($requiresDates) {
|
|
|
|
|
// Get effective dates (item-specific or cart fallback)
|
|
|
|
|
$effectiveFrom = $this->getEffectiveFromDate();
|
|
|
|
|
$effectiveUntil = $this->getEffectiveUntilDate();
|
|
|
|
|
|
|
|
|
|
// Must have both dates (either from item or cart)
|
|
|
|
|
if (is_null($effectiveFrom) || is_null($effectiveUntil)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dates must be valid (from < until)
|
|
|
|
|
if ($effectiveFrom >= $effectiveUntil) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check stock availability for the booking period
|
2025-12-26 07:42:59 +00:00
|
|
|
if (
|
|
|
|
|
$is_booking
|
|
|
|
|
&& !$product->isAvailableForBooking($effectiveFrom, $effectiveUntil, $this->quantity)
|
|
|
|
|
) {
|
|
|
|
|
return false;
|
2025-12-17 11:26:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check pool availability with dates
|
2025-12-26 07:42:59 +00:00
|
|
|
if ($is_pool) {
|
2025-12-17 11:26:26 +00:00
|
|
|
$available = $product->getPoolMaxQuantity($effectiveFrom, $effectiveUntil);
|
|
|
|
|
|
2025-12-26 07:42:59 +00:00
|
|
|
// Get quantity in cart for this product from items BEFORE this one (by id order)
|
|
|
|
|
// This ensures the first N items up to available capacity are marked as ready
|
2025-12-17 11:26:26 +00:00
|
|
|
$cartQuantity = 0;
|
|
|
|
|
if ($this->cart) {
|
|
|
|
|
$cartQuantity = $this->cart->items()
|
|
|
|
|
->where('purchasable_id', $product->getKey())
|
|
|
|
|
->where('purchasable_type', get_class($product))
|
2025-12-26 07:42:59 +00:00
|
|
|
->where('id', '<', $this->id)
|
2025-12-17 11:26:26 +00:00
|
|
|
->sum('quantity');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($available !== PHP_INT_MAX && ($cartQuantity + $this->quantity) > $available) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// For non-booking products, just check stock availability
|
2025-12-26 07:42:59 +00:00
|
|
|
if ($is_pool) {
|
2025-12-17 11:26:26 +00:00
|
|
|
$available = $product->getPoolMaxQuantity();
|
|
|
|
|
|
|
|
|
|
// Get current quantity in cart for this product (excluding this item)
|
|
|
|
|
$cartQuantity = 0;
|
|
|
|
|
if ($this->cart) {
|
|
|
|
|
$cartQuantity = $this->cart->items()
|
|
|
|
|
->where('purchasable_id', $product->getKey())
|
|
|
|
|
->where('purchasable_type', get_class($product))
|
|
|
|
|
->where('id', '!=', $this->id)
|
|
|
|
|
->sum('quantity');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($available !== PHP_INT_MAX && ($cartQuantity + $this->quantity) > $available) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
} elseif ($product->manage_stock) {
|
|
|
|
|
// Check regular stock - sum all stocks for this product
|
|
|
|
|
$totalStock = $product->stocks()->sum('quantity');
|
|
|
|
|
|
|
|
|
|
// If no stock records exist and manage_stock is true, product is not ready
|
|
|
|
|
// (stock records must be created explicitly)
|
|
|
|
|
if ($totalStock === 0 && $product->stocks()->count() > 0) {
|
|
|
|
|
// Has stock records but quantity is 0
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If stock records exist, check cart quantity against stock
|
|
|
|
|
if ($product->stocks()->count() > 0) {
|
|
|
|
|
// Get current quantity in cart for this product (including ALL items of this product)
|
|
|
|
|
$cartQuantity = 0;
|
|
|
|
|
if ($this->cart) {
|
|
|
|
|
$cartQuantity = $this->cart->items()
|
|
|
|
|
->where('purchasable_id', $product->getKey())
|
|
|
|
|
->where('purchasable_type', get_class($product))
|
|
|
|
|
->sum('quantity');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($cartQuantity > $totalStock) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// If no stock records exist, assume product is available (legacy behavior)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-15 11:28:15 +00:00
|
|
|
/**
|
|
|
|
|
* Get required adjustments for this cart item before checkout.
|
|
|
|
|
*
|
|
|
|
|
* Returns an array of fields that need to be set, with suggested field names.
|
|
|
|
|
* For booking products and pools with booking items, dates are required.
|
|
|
|
|
*
|
|
|
|
|
* This method is useful for:
|
|
|
|
|
* - Validating cart items before checkout
|
|
|
|
|
* - Displaying missing information to users
|
|
|
|
|
* - Checking if a cart item needs additional user input
|
|
|
|
|
*
|
|
|
|
|
* Example usage:
|
|
|
|
|
* ```php
|
|
|
|
|
* // Check if cart item needs adjustments
|
|
|
|
|
* $adjustments = $cartItem->requiredAdjustments();
|
|
|
|
|
*
|
|
|
|
|
* if (!empty($adjustments)) {
|
|
|
|
|
* // Item needs dates before checkout
|
|
|
|
|
* // $adjustments = ['from' => 'datetime', 'until' => 'datetime']
|
|
|
|
|
* echo "Please select booking dates";
|
|
|
|
|
* }
|
|
|
|
|
*
|
|
|
|
|
* // Check all cart items before checkout
|
|
|
|
|
* foreach ($cart->items as $item) {
|
|
|
|
|
* $required = $item->requiredAdjustments();
|
|
|
|
|
* if (!empty($required)) {
|
|
|
|
|
* // Handle missing information
|
|
|
|
|
* }
|
|
|
|
|
* }
|
|
|
|
|
* ```
|
|
|
|
|
*
|
|
|
|
|
* @return array Array of required field adjustments, e.g., ['from' => 'datetime', 'until' => 'datetime']
|
|
|
|
|
*/
|
|
|
|
|
public function requiredAdjustments(): array
|
|
|
|
|
{
|
|
|
|
|
$adjustments = [];
|
|
|
|
|
|
|
|
|
|
// Only check if purchasable is a Product
|
|
|
|
|
if ($this->purchasable_type !== config('shop.models.product', Product::class)) {
|
|
|
|
|
return $adjustments;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$product = $this->purchasable;
|
|
|
|
|
|
|
|
|
|
if (!$product) {
|
|
|
|
|
return $adjustments;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-20 11:19:34 +00:00
|
|
|
// Check if price is invalid (null, zero or negative means unavailable)
|
|
|
|
|
if ($this->price === null || $this->price <= 0) {
|
|
|
|
|
$adjustments['price'] = 'unavailable';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Note: Pool items don't require pre-allocation to be ready for checkout.
|
|
|
|
|
// The checkout process can allocate singles on-the-fly via claimPoolStock().
|
|
|
|
|
// The price check above is sufficient - if price is null, item is unavailable.
|
|
|
|
|
|
2025-12-15 11:28:15 +00:00
|
|
|
// Check if dates are required (for booking products or pools with booking items)
|
|
|
|
|
$requiresDates = $product->isBooking() ||
|
|
|
|
|
($product->isPool() && $product->hasBookingSingleItems());
|
|
|
|
|
|
|
|
|
|
if ($requiresDates) {
|
|
|
|
|
if (is_null($this->from)) {
|
|
|
|
|
$adjustments['from'] = 'datetime';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (is_null($this->until)) {
|
|
|
|
|
$adjustments['until'] = 'datetime';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $adjustments;
|
|
|
|
|
}
|
2025-12-15 13:10:59 +00:00
|
|
|
|
2025-12-17 11:26:26 +00:00
|
|
|
/**
|
|
|
|
|
* Get the effective 'from' date for this cart item.
|
2025-12-19 08:53:44 +00:00
|
|
|
* Returns the item's specific date if set, otherwise falls back to the cart's from.
|
2025-12-17 11:26:26 +00:00
|
|
|
*
|
|
|
|
|
* @return \Carbon\Carbon|null
|
|
|
|
|
*/
|
|
|
|
|
public function getEffectiveFromDate(): ?\Carbon\Carbon
|
|
|
|
|
{
|
|
|
|
|
if ($this->from) {
|
|
|
|
|
return $this->from;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-19 08:53:44 +00:00
|
|
|
return $this->cart?->from;
|
2025-12-17 11:26:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the effective 'until' date for this cart item.
|
2025-12-19 08:53:44 +00:00
|
|
|
* Returns the item's specific date if set, otherwise falls back to the cart's until.
|
2025-12-17 11:26:26 +00:00
|
|
|
*
|
|
|
|
|
* @return \Carbon\Carbon|null
|
|
|
|
|
*/
|
|
|
|
|
public function getEffectiveUntilDate(): ?\Carbon\Carbon
|
|
|
|
|
{
|
|
|
|
|
if ($this->until) {
|
|
|
|
|
return $this->until;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-19 08:53:44 +00:00
|
|
|
return $this->cart?->until;
|
2025-12-17 11:26:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this item has effective dates (either its own or from cart).
|
|
|
|
|
*
|
|
|
|
|
* @return bool
|
|
|
|
|
*/
|
|
|
|
|
public function hasEffectiveDates(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->getEffectiveFromDate() !== null && $this->getEffectiveUntilDate() !== null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-15 13:10:59 +00:00
|
|
|
/**
|
|
|
|
|
* Update the booking dates for this cart item.
|
|
|
|
|
* Automatically recalculates price based on the new date range.
|
|
|
|
|
*
|
2025-12-18 08:57:33 +00:00
|
|
|
* IMPORTANT: This method uses cart-aware pricing!
|
|
|
|
|
* For pool products, it automatically considers which price tiers are already
|
|
|
|
|
* used in the cart to determine the next available price based on the pricing
|
|
|
|
|
* strategy (LOWEST, HIGHEST, AVERAGE).
|
|
|
|
|
*
|
|
|
|
|
* The method passes the NEW dates to getCurrentPrice() to ensure accurate
|
|
|
|
|
* pricing calculations. Without passing dates, the pricing logic would use
|
|
|
|
|
* stale dates from the cart item before the update, potentially selecting
|
|
|
|
|
* the wrong price tier.
|
|
|
|
|
*
|
2025-12-17 11:26:26 +00:00
|
|
|
* NOTE: This method allows setting any dates, even if they're not available.
|
|
|
|
|
* Use the is_ready_to_checkout attribute to check if the dates are valid.
|
|
|
|
|
*
|
2025-12-17 15:43:22 +00:00
|
|
|
* @param \DateTimeInterface|string|null $from Start date (DateTimeInterface or parsable string)
|
|
|
|
|
* @param \DateTimeInterface|string|null $until End date (DateTimeInterface or parsable string)
|
2025-12-15 13:10:59 +00:00
|
|
|
* @return $this
|
|
|
|
|
* @throws \Exception If dates are invalid
|
|
|
|
|
*/
|
2025-12-17 11:26:26 +00:00
|
|
|
public function updateDates(
|
2025-12-17 15:43:22 +00:00
|
|
|
\DateTimeInterface|string|null $from = null,
|
|
|
|
|
\DateTimeInterface|string|null $until = null
|
2025-12-17 11:26:26 +00:00
|
|
|
): self {
|
2025-12-17 15:43:22 +00:00
|
|
|
// Parse string dates using Carbon
|
|
|
|
|
if (is_string($from)) {
|
|
|
|
|
$from = \Carbon\Carbon::parse($from);
|
|
|
|
|
}
|
|
|
|
|
if (is_string($until)) {
|
|
|
|
|
$until = \Carbon\Carbon::parse($until);
|
|
|
|
|
}
|
2025-12-18 08:57:33 +00:00
|
|
|
|
|
|
|
|
// Validate that both dates are provided
|
|
|
|
|
if (!$from || !$until) {
|
|
|
|
|
throw new \Exception("Both 'from' and 'until' dates are required.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate date order
|
|
|
|
|
if ($from >= $until) {
|
2025-12-15 13:10:59 +00:00
|
|
|
throw new \Exception("The 'from' date must be before the 'until' date.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$product = $this->purchasable;
|
|
|
|
|
|
|
|
|
|
if (!$product || !($product instanceof Product)) {
|
|
|
|
|
throw new \Exception("Cannot update dates for non-product items.");
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 16:57:17 +00:00
|
|
|
// Calculate days using per-minute precision
|
|
|
|
|
$days = $this->calculateBookingDays($from, $until);
|
2025-12-15 13:10:59 +00:00
|
|
|
|
2025-12-20 11:43:28 +00:00
|
|
|
// For pool products with an allocated single, use the allocated single's price
|
|
|
|
|
// This ensures consistency when reallocatePoolItems has already assigned a specific single
|
2025-12-30 08:29:43 +00:00
|
|
|
// The product_id column stores the actual single product being purchased
|
|
|
|
|
$allocatedSingleItemId = $this->product_id;
|
2025-12-20 11:43:28 +00:00
|
|
|
|
|
|
|
|
if ($product->isPool() && $allocatedSingleItemId) {
|
2025-12-30 08:29:43 +00:00
|
|
|
// Get the allocated single item from the product_id column
|
|
|
|
|
$allocatedSingle = $this->product;
|
2025-12-20 11:43:28 +00:00
|
|
|
|
|
|
|
|
if ($allocatedSingle) {
|
|
|
|
|
// Get price from the allocated single, with fallback to pool price
|
|
|
|
|
$priceModel = $allocatedSingle->defaultPrice()->first();
|
|
|
|
|
$pricePerDay = $priceModel?->getCurrentPrice($allocatedSingle->isOnSale());
|
|
|
|
|
$regularPricePerDay = $priceModel?->getCurrentPrice(false) ?? $pricePerDay;
|
|
|
|
|
|
|
|
|
|
// Fallback to pool price if single has no price
|
|
|
|
|
if ($pricePerDay === null && $product->hasPrice()) {
|
|
|
|
|
$poolPriceModel = $product->defaultPrice()->first();
|
|
|
|
|
$pricePerDay = $poolPriceModel?->getCurrentPrice($product->isOnSale());
|
|
|
|
|
$regularPricePerDay = $poolPriceModel?->getCurrentPrice(false) ?? $pricePerDay;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Allocated single not found - this is an error state, mark as unavailable
|
|
|
|
|
$this->update([
|
|
|
|
|
'from' => $from,
|
|
|
|
|
'until' => $until,
|
|
|
|
|
'price' => null,
|
|
|
|
|
'regular_price' => null,
|
|
|
|
|
'unit_amount' => null,
|
|
|
|
|
'subtotal' => null,
|
|
|
|
|
]);
|
|
|
|
|
return $this->fresh();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Non-pool product or pool without allocation: use getCurrentPrice
|
|
|
|
|
// Pass dates to ensure accurate pricing for pool products during date updates
|
|
|
|
|
// Pass cart item ID to exclude this item from usage calculation
|
|
|
|
|
$pricePerDay = $product->getCurrentPrice(null, $this->cart, $from, $until, $this->id);
|
|
|
|
|
$regularPricePerDay = $product->getCurrentPrice(false, $this->cart, $from, $until, $this->id) ?? $pricePerDay;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If no price found, mark as unavailable
|
|
|
|
|
if ($pricePerDay === null) {
|
|
|
|
|
$this->update([
|
|
|
|
|
'from' => $from,
|
|
|
|
|
'until' => $until,
|
|
|
|
|
'price' => null,
|
|
|
|
|
'regular_price' => null,
|
|
|
|
|
'unit_amount' => null,
|
|
|
|
|
'subtotal' => null,
|
|
|
|
|
]);
|
|
|
|
|
return $this->fresh();
|
|
|
|
|
}
|
2025-12-15 13:10:59 +00:00
|
|
|
|
2025-12-18 11:21:29 +00:00
|
|
|
// Store the base unit_amount (price for 1 quantity, 1 day) in cents
|
|
|
|
|
$unitAmount = (int) round($pricePerDay);
|
|
|
|
|
|
2025-12-18 09:54:42 +00:00
|
|
|
// Calculate new prices and round to nearest cent for consistency
|
|
|
|
|
$pricePerUnit = (int) round($pricePerDay * $days);
|
|
|
|
|
$regularPricePerUnit = (int) round($regularPricePerDay * $days);
|
2025-12-15 13:10:59 +00:00
|
|
|
|
|
|
|
|
$this->update([
|
|
|
|
|
'from' => $from,
|
|
|
|
|
'until' => $until,
|
|
|
|
|
'price' => $pricePerUnit,
|
|
|
|
|
'regular_price' => $regularPricePerUnit,
|
2025-12-18 11:21:29 +00:00
|
|
|
'unit_amount' => $unitAmount,
|
2025-12-15 13:10:59 +00:00
|
|
|
'subtotal' => $pricePerUnit * $this->quantity,
|
|
|
|
|
]);
|
|
|
|
|
|
2025-12-17 11:26:26 +00:00
|
|
|
// Note: is_ready_to_checkout will automatically reflect if these dates are available
|
2025-12-15 13:10:59 +00:00
|
|
|
return $this->fresh();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Set the 'from' date for this cart item.
|
|
|
|
|
*
|
2025-12-17 15:43:22 +00:00
|
|
|
* @param \DateTimeInterface|string $from Start date (DateTimeInterface or parsable string)
|
2025-12-15 13:10:59 +00:00
|
|
|
* @return $this
|
2025-12-17 11:26:26 +00:00
|
|
|
* @throws InvalidDateRangeException
|
2025-12-15 13:10:59 +00:00
|
|
|
*/
|
2025-12-17 15:50:56 +00:00
|
|
|
public function setFromDate(\DateTimeInterface|string|int|float $from): self
|
2025-12-15 13:10:59 +00:00
|
|
|
{
|
2025-12-17 15:43:22 +00:00
|
|
|
// Parse string dates using Carbon
|
2025-12-17 15:50:56 +00:00
|
|
|
if (is_string($from) || is_numeric($from)) {
|
2025-12-17 15:43:22 +00:00
|
|
|
$from = \Carbon\Carbon::parse($from);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 08:57:33 +00:00
|
|
|
// Refresh to get current state
|
|
|
|
|
$this->refresh();
|
|
|
|
|
|
2025-12-15 13:10:59 +00:00
|
|
|
if ($this->until && $from >= $this->until) {
|
2025-12-17 11:26:26 +00:00
|
|
|
throw new InvalidDateRangeException();
|
2025-12-15 13:10:59 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-18 08:57:33 +00:00
|
|
|
// Get the current until date before updating
|
|
|
|
|
$currentUntil = $this->until;
|
2025-12-15 13:10:59 +00:00
|
|
|
|
2025-12-18 08:57:33 +00:00
|
|
|
// If both dates are set, use updateDates to recalculate pricing
|
|
|
|
|
if ($currentUntil) {
|
|
|
|
|
return $this->updateDates($from, $currentUntil);
|
2025-12-15 13:10:59 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-18 08:57:33 +00:00
|
|
|
// Otherwise just update the from date
|
|
|
|
|
$this->update(['from' => $from]);
|
|
|
|
|
return $this->fresh();
|
2025-12-15 13:10:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Set the 'until' date for this cart item.
|
|
|
|
|
*
|
2025-12-17 15:43:22 +00:00
|
|
|
* @param \DateTimeInterface|string $until End date (DateTimeInterface or parsable string)
|
2025-12-15 13:10:59 +00:00
|
|
|
* @return $this
|
2025-12-17 11:26:26 +00:00
|
|
|
* @throws InvalidDateRangeException
|
2025-12-15 13:10:59 +00:00
|
|
|
*/
|
2025-12-17 15:50:56 +00:00
|
|
|
public function setUntilDate(\DateTimeInterface|string|int|float $until): self
|
2025-12-15 13:10:59 +00:00
|
|
|
{
|
2025-12-17 15:43:22 +00:00
|
|
|
// Parse string dates using Carbon
|
2025-12-17 15:50:56 +00:00
|
|
|
if (is_string($until) || is_numeric($until)) {
|
2025-12-17 15:43:22 +00:00
|
|
|
$until = \Carbon\Carbon::parse($until);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 08:57:33 +00:00
|
|
|
// Refresh to get current state
|
|
|
|
|
$this->refresh();
|
|
|
|
|
|
2025-12-15 13:10:59 +00:00
|
|
|
if ($this->from && $this->from >= $until) {
|
2025-12-17 11:26:26 +00:00
|
|
|
throw new InvalidDateRangeException();
|
2025-12-15 13:10:59 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-18 08:57:33 +00:00
|
|
|
// Get the current from date before updating
|
|
|
|
|
$currentFrom = $this->from;
|
2025-12-17 11:26:26 +00:00
|
|
|
|
2025-12-18 08:57:33 +00:00
|
|
|
// If both dates are set, use updateDates to recalculate pricing
|
|
|
|
|
if ($currentFrom) {
|
|
|
|
|
return $this->updateDates($currentFrom, $until);
|
2025-12-15 13:10:59 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-18 08:57:33 +00:00
|
|
|
// Otherwise just update the until date
|
|
|
|
|
$this->update(['until' => $until]);
|
|
|
|
|
return $this->fresh();
|
2025-12-15 13:10:59 +00:00
|
|
|
}
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|