2025-11-21 10:49:41 +00:00
< ? php
namespace Blax\Shop\Models ;
2025-11-23 14:07:12 +00:00
use Blax\Shop\Contracts\Cartable ;
2025-12-03 12:59:01 +00:00
use Blax\Shop\Enums\CartStatus ;
use Blax\Shop\Enums\ProductType ;
2025-12-19 08:53:44 +00:00
use Blax\Shop\Enums\PurchaseStatus ;
2025-12-19 13:26:57 +00:00
use Blax\Shop\Exceptions\CartableInterfaceException ;
use Blax\Shop\Exceptions\CartAlreadyConvertedException ;
use Blax\Shop\Exceptions\CartDatesRequiredException ;
use Blax\Shop\Exceptions\CartEmptyException ;
use Blax\Shop\Exceptions\CartItemMissingInformationException ;
2025-12-17 11:26:26 +00:00
use Blax\Shop\Exceptions\InvalidDateRangeException ;
use Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException ;
2025-12-19 13:26:57 +00:00
use Blax\Shop\Exceptions\NotEnoughStockException ;
use Blax\Shop\Exceptions\PriceCalculationException ;
use Blax\Shop\Exceptions\ProductHasNoPriceException ;
2025-12-17 08:24:42 +00:00
use Blax\Shop\Services\CartService ;
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-21 10:49:41 +00:00
use Blax\Workkit\Traits\HasExpiration ;
2025-12-15 13:10:59 +00:00
use Carbon\Carbon ;
2025-11-21 10:49:41 +00:00
use Illuminate\Database\Eloquent\Concerns\HasUuids ;
2025-11-29 11:05:02 +00:00
use Illuminate\Database\Eloquent\Factories\HasFactory ;
2025-11-21 10:49:41 +00:00
use Illuminate\Database\Eloquent\Model ;
use Illuminate\Database\Eloquent\Relations\HasMany ;
use Illuminate\Database\Eloquent\Relations\MorphTo ;
2025-12-19 08:53:44 +00:00
use Illuminate\Support\Facades\DB ;
2025-11-21 10:49:41 +00:00
class Cart extends Model
{
2025-12-18 15:54:33 +00:00
use HasUuids , HasExpiration , HasFactory , HasBookingPriceCalculation , ChecksIfBooking ;
2025-11-21 10:49:41 +00:00
protected $fillable = [
'session_id' ,
'customer_type' ,
'customer_id' ,
'currency' ,
'status' ,
'last_activity_at' ,
'expires_at' ,
'converted_at' ,
'meta' ,
2025-12-19 08:53:44 +00:00
'from' ,
'until' ,
2025-11-21 10:49:41 +00:00
];
protected $casts = [
2025-12-03 12:59:01 +00:00
'status' => CartStatus :: class ,
2025-11-21 10:49:41 +00:00
'expires_at' => 'datetime' ,
'converted_at' => 'datetime' ,
'last_activity_at' => 'datetime' ,
'meta' => 'object' ,
2025-12-19 08:53:44 +00:00
'from' => 'datetime' ,
'until' => 'datetime' ,
2025-12-17 11:26:26 +00:00
];
protected $appends = [
'is_full_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.carts' , 'carts' );
}
2025-12-18 11:21:29 +00:00
protected static function booted ()
{
static :: deleting ( function ( $cart ) {
$cart -> items () -> delete ();
});
}
2025-11-21 10:49:41 +00:00
public function customer () : MorphTo
{
return $this -> morphTo ();
}
// Alias for backward compatibility
public function user ()
{
return $this -> customer ();
}
public function items () : HasMany
{
return $this -> hasMany ( config ( 'shop.models.cart_item' ), 'cart_id' );
}
public function purchases () : HasMany
{
return $this -> hasMany ( config ( 'shop.models.product_purchase' , \Blax\Shop\Models\ProductPurchase :: class ), 'cart_id' );
}
public function getTotal () : float
{
2025-12-16 12:58:03 +00:00
return $this -> items () -> sum ( 'subtotal' );
2025-11-21 10:49:41 +00:00
}
public function getTotalItems () : int
{
return $this -> items -> sum ( 'quantity' );
}
2025-12-17 11:26:26 +00:00
/**
* Check if all cart items are booking products
*/
public function getIsFullBookingAttribute () : bool
{
if ( $this -> items -> isEmpty ()) {
return false ;
}
return $this -> items -> every ( fn ( $item ) => $item -> is_booking );
}
2025-12-18 15:54:33 +00:00
/**
* Check if the cart contains at least one booking item
*/
public function isBooking () : bool
{
if ( $this -> items -> isEmpty ()) {
return false ;
}
return $this -> items -> contains ( fn ( $item ) => $item -> is_booking );
}
2025-12-17 11:26:26 +00:00
/**
* Get count of booking items in the cart
*/
public function bookingItems () : int
{
return $this -> items -> filter ( fn ( $item ) => $item -> is_booking ) -> count ();
}
/**
* Get array of stripe_price_id from each cart item ' s price .
* Returns array with nulls for items without stripe_price_id .
*
* @ return array < string | null >
*/
public function stripePriceIds () : array
{
return $this -> items -> map ( function ( $item ) {
if ( ! $item -> price_id ) {
return null ;
}
// Use the relationship method, not property access
$price = $item -> price () -> first ();
return $price ? $price -> stripe_price_id : null ;
}) -> toArray ();
}
/**
* Check if cart is ready for checkout .
*
* Returns true if all cart items are ready for checkout .
*
* @ return bool
*/
public function getIsReadyToCheckoutAttribute () : bool
{
if ( $this -> items -> isEmpty ()) {
return false ;
}
return $this -> items -> every ( fn ( $item ) => $item -> is_ready_to_checkout );
}
2025-12-15 11:28:15 +00:00
/**
* Get all cart items that require adjustments before checkout .
*
* This method checks all cart items and returns a collection of items
* that need additional information ( like booking dates ) before checkout .
*
* Example usage :
* `` ` php
* $incompleteItems = $cart -> getItemsRequiringAdjustments ();
*
* if ( $incompleteItems -> isNotEmpty ()) {
* foreach ( $incompleteItems as $item ) {
* $adjustments = $item -> requiredAdjustments ();
* // Display what's needed: ['from' => 'datetime', 'until' => 'datetime']
* }
* }
* `` `
*
* @ return \Illuminate\Support\Collection Collection of CartItem models requiring adjustments
*/
public function getItemsRequiringAdjustments ()
{
return $this -> items -> filter ( function ( $item ) {
return ! empty ( $item -> requiredAdjustments ());
});
}
/**
* Check if cart is ready for checkout .
*
* Returns true if all cart items have all required information set .
* For booking products and pools with booking items , this means dates must be set .
*
* @ return bool True if ready for checkout , false if any items need adjustments
*/
public function isReadyForCheckout () : bool
{
return $this -> getItemsRequiringAdjustments () -> isEmpty ();
}
2025-12-17 11:26:26 +00:00
/**
* Set the default date range for the cart .
* Items without specific dates will use these as fallback .
*
2025-12-17 15:43:22 +00:00
* @ param \DateTimeInterface | string $from Start date ( DateTimeInterface or parsable string )
* @ param \DateTimeInterface | string $until End date ( DateTimeInterface or parsable string )
2025-12-17 11:26:26 +00:00
* @ param bool $validateAvailability Whether to validate product availability for the timespan
* @ return $this
* @ throws InvalidDateRangeException
* @ throws NotEnoughAvailableInTimespanException
*/
2025-12-17 15:50:56 +00:00
public function setDates (
2025-12-19 13:26:57 +00:00
\DateTimeInterface | string | int | float | null $from ,
\DateTimeInterface | string | int | float | null $until ,
2025-12-19 09:57:26 +00:00
bool $validateAvailability = true ,
bool $overwrite_item_dates = true
2025-12-17 15:50:56 +00:00
) : self {
2025-12-17 15:43:22 +00:00
// Parse string dates using Carbon
2025-12-19 13:26:57 +00:00
if ( $from !== null && ( is_string ( $from ) || is_numeric ( $from ))) {
2025-12-17 15:43:22 +00:00
$from = Carbon :: parse ( $from );
}
2025-12-19 13:26:57 +00:00
if ( $until !== null && ( is_string ( $until ) || is_numeric ( $until ))) {
2025-12-17 15:43:22 +00:00
$until = Carbon :: parse ( $until );
}
2025-12-19 13:26:57 +00:00
// Always update cart dates with provided values
$updateData = [];
if ( $from !== null ) {
$updateData [ 'from' ] = $from ;
}
if ( $until !== null ) {
$updateData [ 'until' ] = $until ;
2025-12-17 11:26:26 +00:00
}
2025-12-19 13:26:57 +00:00
if ( ! empty ( $updateData )) {
$this -> update ( $updateData );
$this -> refresh ();
2025-12-17 11:26:26 +00:00
}
2025-12-19 13:26:57 +00:00
// Get the current dates (may include one from database if only one was updated)
$effectiveFrom = $from ? ? $this -> from ;
$effectiveUntil = $until ? ? $this -> until ;
// Only calculate/validate if BOTH dates are set
if ( $effectiveFrom && $effectiveUntil ) {
// For calculations, swap if dates are backwards
$calcFrom = $effectiveFrom ;
$calcUntil = $effectiveUntil ;
if ( $effectiveFrom > $effectiveUntil ) {
$calcFrom = $effectiveUntil ;
$calcUntil = $effectiveFrom ;
}
2025-12-17 11:26:26 +00:00
2025-12-19 13:26:57 +00:00
if ( $validateAvailability ) {
// Validate against the correctly ordered dates
$this -> validateDateAvailability ( $calcFrom , $calcUntil , $overwrite_item_dates );
}
// Update cart items with correctly ordered dates
$this -> applyDatesToItems (
$validateAvailability ,
$overwrite_item_dates ,
$calcFrom ,
$calcUntil
);
}
2025-12-18 14:33:47 +00:00
2025-12-17 11:26:26 +00:00
return $this -> fresh ();
}
/**
* Set the 'from' date for the cart .
*
2025-12-17 15:43:22 +00:00
* @ param \DateTimeInterface | string $from Start date ( DateTimeInterface or parsable string )
2025-12-17 11:26:26 +00:00
* @ param bool $validateAvailability Whether to validate product availability for the timespan
* @ return $this
* @ throws NotEnoughAvailableInTimespanException
*/
2025-12-17 15:50:56 +00:00
public function setFromDate (
\DateTimeInterface | string | int | float $from ,
bool $validateAvailability = true
) : self {
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 :: parse ( $from );
}
2025-12-19 13:26:57 +00:00
// Always update the from date
$this -> update ([ 'from' => $from ]);
$this -> refresh ();
// Only calculate if both dates are set
if ( $this -> until ) {
// For calculations, swap if dates are backwards
$calcFrom = $from ;
$calcUntil = $this -> until ;
if ( $from > $this -> until ) {
$calcFrom = $this -> until ;
$calcUntil = $from ;
}
2025-12-17 11:26:26 +00:00
2025-12-19 13:26:57 +00:00
if ( $validateAvailability ) {
$this -> validateDateAvailability ( $calcFrom , $calcUntil );
}
2025-12-20 14:08:08 +00:00
// Update cart items with new dates and recalculate prices
$this -> applyDatesToItems (
$validateAvailability ,
true ,
$calcFrom ,
$calcUntil
);
2025-12-17 11:26:26 +00:00
}
return $this -> fresh ();
}
/**
* Set the 'until' date for the cart .
*
2025-12-17 15:43:22 +00:00
* @ param \DateTimeInterface | string $until End date ( DateTimeInterface or parsable string )
2025-12-17 11:26:26 +00:00
* @ param bool $validateAvailability Whether to validate product availability for the timespan
* @ return $this
* @ throws NotEnoughAvailableInTimespanException
*/
2025-12-17 15:50:56 +00:00
public function setUntilDate ( \DateTimeInterface | string | int | float $until , bool $validateAvailability = true ) : self
2025-12-17 11:26:26 +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 :: parse ( $until );
}
2025-12-19 13:26:57 +00:00
// Always update the until date
$this -> update ([ 'until' => $until ]);
$this -> refresh ();
// Only calculate if both dates are set
if ( $this -> from ) {
// For calculations, swap if dates are backwards
$calcFrom = $this -> from ;
$calcUntil = $until ;
if ( $this -> from > $until ) {
$calcFrom = $until ;
$calcUntil = $this -> from ;
}
2025-12-17 11:26:26 +00:00
2025-12-19 13:26:57 +00:00
if ( $validateAvailability ) {
$this -> validateDateAvailability ( $calcFrom , $calcUntil );
}
2025-12-20 14:08:08 +00:00
// Update cart items with new dates and recalculate prices
$this -> applyDatesToItems (
$validateAvailability ,
true ,
$calcFrom ,
$calcUntil
);
2025-12-17 11:26:26 +00:00
}
return $this -> fresh ();
}
/**
* Apply cart dates to all items that don ' t have their own dates set .
*
* @ param bool $validateAvailability Whether to validate product availability for the timespan
2025-12-19 09:08:24 +00:00
* @ param bool $overwrite If true , overwrites existing item dates . If false , only sets null fields .
2025-12-19 13:26:57 +00:00
* @ param \DateTimeInterface | null $from Optional from date ( uses cart ' s from if not provided )
* @ param \DateTimeInterface | null $until Optional until date ( uses cart ' s until if not provided )
2025-12-17 11:26:26 +00:00
* @ return $this
* @ throws NotEnoughAvailableInTimespanException
*/
2025-12-19 13:26:57 +00:00
public function applyDatesToItems (
bool $validateAvailability = true ,
bool $overwrite = false ,
? \DateTimeInterface $from = null ,
? \DateTimeInterface $until = null
) : self {
// Use provided dates or fall back to cart dates
$fromDate = $from ? ? $this -> from ;
$untilDate = $until ? ? $this -> until ;
if ( ! $fromDate || ! $untilDate ) {
2025-12-17 11:26:26 +00:00
return $this ;
}
2025-12-20 10:22:04 +00:00
// First, reallocate pool items if pricing strategy suggests better allocation with new dates
$this -> reallocatePoolItems ( $fromDate , $untilDate , $overwrite );
// Refresh items relationship to get updated meta values
$this -> load ( 'items' );
// Track pool products to validate total allocation across all cart items
$poolValidation = [];
2025-12-17 11:26:26 +00:00
foreach ( $this -> items as $item ) {
2025-12-19 09:08:24 +00:00
// Only apply to booking items
if ( $item -> is_booking ) {
// Determine which dates to apply based on overwrite setting
$shouldApplyFrom = $overwrite || ! $item -> from ;
$shouldApplyUntil = $overwrite || ! $item -> until ;
if ( ! $shouldApplyFrom && ! $shouldApplyUntil ) {
continue ;
}
2025-12-19 13:26:57 +00:00
$itemFrom = $shouldApplyFrom ? $fromDate : $item -> from ;
$itemUntil = $shouldApplyUntil ? $untilDate : $item -> until ;
2025-12-19 09:08:24 +00:00
2025-12-17 11:26:26 +00:00
if ( $validateAvailability ) {
$product = $item -> purchasable ;
2025-12-20 10:22:04 +00:00
2025-12-20 11:19:34 +00:00
// For pool products, check if allocated by reallocatePoolItems
2025-12-20 10:22:04 +00:00
if ( $product instanceof Product && $product -> isPool ()) {
2025-12-20 11:19:34 +00:00
$meta = $item -> getMeta ();
$allocatedSingleItemId = $meta -> allocated_single_item_id ? ? null ;
// If this item was NOT allocated (no single assigned), skip updateDates
// to preserve the null price set by reallocatePoolItems
if ( empty ( $allocatedSingleItemId )) {
// Just update the dates without recalculating price
$item -> update ([
'from' => $itemFrom ,
'until' => $itemUntil ,
]);
continue ;
}
2025-12-20 10:22:04 +00:00
$poolKey = $product -> id . '|' . $itemFrom -> format ( 'Y-m-d H:i:s' ) . '|' . $itemUntil -> format ( 'Y-m-d H:i:s' );
if ( ! isset ( $poolValidation [ $poolKey ])) {
$poolValidation [ $poolKey ] = [
'product' => $product ,
'from' => $itemFrom ,
'until' => $itemUntil ,
'requested' => 0 ,
'allocated' => 0 ,
];
}
$poolValidation [ $poolKey ][ 'requested' ] += $item -> quantity ;
2025-12-20 11:19:34 +00:00
$poolValidation [ $poolKey ][ 'allocated' ] += $item -> quantity ;
2025-12-20 10:22:04 +00:00
} elseif ( $product && ! $product -> isAvailableForBooking ( $itemFrom , $itemUntil , $item -> quantity )) {
2025-12-20 11:19:34 +00:00
// Non-pool booking item is not available - mark as unavailable
// Don't throw exception - let user adjust dates freely
$item -> update ([
'from' => $itemFrom ,
'until' => $itemUntil ,
'price' => null ,
'subtotal' => null ,
'unit_amount' => null ,
]);
// Skip updateDates() since we already set the dates with null price
continue ;
2025-12-17 11:26:26 +00:00
}
}
2025-12-19 13:26:57 +00:00
$item -> updateDates ( $itemFrom , $itemUntil );
2025-12-17 11:26:26 +00:00
}
}
2025-12-20 11:19:34 +00:00
// Pool validation is now handled by reallocatePoolItems() which marks
// unallocated items with null price instead of throwing exceptions.
// This allows users to freely adjust dates without exceptions.
// Validation happens at checkout time via isReadyForCheckout().
2025-12-20 10:22:04 +00:00
2025-12-17 11:26:26 +00:00
return $this -> fresh ();
}
2025-12-20 10:22:04 +00:00
/**
* Reallocate pool items to optimize pricing when dates change .
*
* When dates change , check if better - priced single items become available
* according to the pool ' s pricing strategy ( LOWEST , HIGHEST , etc . )
*
* @ param \DateTimeInterface $from New start date
* @ param \DateTimeInterface $until New end date
* @ param bool $overwrite Whether to apply to all items or only those without dates
* @ return void
*/
protected function reallocatePoolItems ( \DateTimeInterface $from , \DateTimeInterface $until , bool $overwrite = true ) : void
{
// Group cart items by pool product
$poolItems = $this -> items () -> get ()
-> filter ( function ( $item ) {
$product = $item -> purchasable ;
return $product instanceof Product && $product -> isPool ();
})
-> groupBy ( 'purchasable_id' );
foreach ( $poolItems as $poolId => $items ) {
$poolProduct = $items -> first () -> purchasable ;
if ( ! $poolProduct ) {
continue ;
}
// Get all available single items for the new dates with their prices
$strategy = $poolProduct -> getPricingStrategy ();
// Eager load stocks relationship to ensure fresh data
$singleItems = $poolProduct -> singleProducts () -> with ( 'stocks' ) -> get ();
if ( $singleItems -> isEmpty ()) {
continue ;
}
2025-12-20 14:08:08 +00:00
// Build list of available singles with their prices for new dates
$singlesWithPrices = [];
2025-12-20 10:22:04 +00:00
foreach ( $singleItems as $single ) {
2025-12-20 14:08:08 +00:00
// Get available stock at the booking start date
// This already accounts for claims via the DECREASE entries they create
$effectiveAvailable = $single -> getAvailableStock ( $from );
2025-12-20 10:22:04 +00:00
2025-12-20 14:08:08 +00:00
if ( $effectiveAvailable > 0 ) {
2025-12-20 10:22:04 +00:00
$priceModel = $single -> defaultPrice () -> first ();
$price = $priceModel ? -> getCurrentPrice ( $single -> isOnSale ());
// Fallback to pool price if single has no price
if ( $price === null && $poolProduct -> hasPrice ()) {
$priceModel = $poolProduct -> defaultPrice () -> first ();
$price = $priceModel ? -> getCurrentPrice ( $poolProduct -> isOnSale ());
}
if ( $price !== null ) {
2025-12-20 14:08:08 +00:00
$singlesWithPrices [] = [
2025-12-20 10:22:04 +00:00
'single' => $single ,
'price' => $price ,
'price_id' => $priceModel ? -> id ,
2025-12-20 14:08:08 +00:00
'available' => $effectiveAvailable ,
2025-12-20 10:22:04 +00:00
];
}
}
}
2025-12-20 14:08:08 +00:00
if ( empty ( $singlesWithPrices )) {
2025-12-20 11:19:34 +00:00
// No singles available for this period - mark ALL pool items as unavailable
foreach ( $items as $cartItem ) {
// Only update if we should overwrite or item has no dates yet
if ( ! $overwrite && $cartItem -> from && $cartItem -> until ) {
continue ;
}
// Clear allocation and set price to null to indicate unavailable
$cartItem -> updateMetaKey ( 'allocated_single_item_id' , null );
$cartItem -> updateMetaKey ( 'allocated_single_item_name' , null );
$cartItem -> update ([
'price' => null ,
'subtotal' => null ,
'unit_amount' => null ,
]);
}
2025-12-20 10:22:04 +00:00
continue ;
}
// Sort by pricing strategy
2025-12-20 14:08:08 +00:00
usort ( $singlesWithPrices , function ( $a , $b ) use ( $strategy ) {
2025-12-20 10:22:04 +00:00
return match ( $strategy ) {
\Blax\Shop\Enums\PricingStrategy :: LOWEST => $a [ 'price' ] <=> $b [ 'price' ],
\Blax\Shop\Enums\PricingStrategy :: HIGHEST => $b [ 'price' ] <=> $a [ 'price' ],
\Blax\Shop\Enums\PricingStrategy :: AVERAGE => 0 ,
};
});
// Reallocate cart items to optimal singles
2025-12-20 14:08:08 +00:00
// Track usage per single to properly allocate considering quantities
// If a single can't accommodate a cart item's full quantity, split the cart item
$singleUsage = []; // single_id => quantity used
// Use singlesWithPrices directly as our ordered list
$orderedSingles = $singlesWithPrices ;
2025-12-20 10:22:04 +00:00
foreach ( $items as $cartItem ) {
// Only reallocate if we should overwrite or item has no dates yet
if ( ! $overwrite && $cartItem -> from && $cartItem -> until ) {
continue ;
}
2025-12-20 14:08:08 +00:00
$neededQty = $cartItem -> quantity ;
2025-12-20 10:22:04 +00:00
$allocated = false ;
2025-12-20 14:08:08 +00:00
// Try to find a single that can accommodate the full quantity
foreach ( $orderedSingles as $singleInfo ) {
$single = $singleInfo [ 'single' ];
$usedFromSingle = $singleUsage [ $single -> id ] ? ? 0 ;
$remainingFromSingle = $singleInfo [ 'available' ] - $usedFromSingle ;
if ( $remainingFromSingle >= $neededQty ) {
// This single can accommodate the cart item's full quantity
$cartItem -> updateMetaKey ( 'allocated_single_item_id' , $single -> id );
$cartItem -> updateMetaKey ( 'allocated_single_item_name' , $single -> name );
2025-12-20 10:22:04 +00:00
// Update price_id if changed
2025-12-20 14:08:08 +00:00
if ( $singleInfo [ 'price_id' ] && $singleInfo [ 'price_id' ] !== $cartItem -> price_id ) {
$cartItem -> update ([ 'price_id' => $singleInfo [ 'price_id' ]]);
2025-12-20 10:22:04 +00:00
}
2025-12-20 14:08:08 +00:00
// Track usage
$singleUsage [ $single -> id ] = $usedFromSingle + $neededQty ;
2025-12-20 10:22:04 +00:00
$allocated = true ;
break ;
}
}
if ( ! $allocated ) {
2025-12-20 14:08:08 +00:00
// No single can accommodate the full quantity
// Try to split: use as much as possible from the first available single,
// then create new cart items for the rest
$remainingQty = $neededQty ;
$firstAllocation = true ;
foreach ( $orderedSingles as $singleInfo ) {
if ( $remainingQty <= 0 ) break ;
$single = $singleInfo [ 'single' ];
$usedFromSingle = $singleUsage [ $single -> id ] ? ? 0 ;
$availableFromSingle = $singleInfo [ 'available' ] - $usedFromSingle ;
if ( $availableFromSingle <= 0 ) continue ;
$qtyToAllocate = min ( $remainingQty , $availableFromSingle );
if ( $firstAllocation ) {
// Update the original cart item with reduced quantity
// Also update subtotal to match the new quantity
$newSubtotal = $cartItem -> price * $qtyToAllocate ;
$cartItem -> update ([
'quantity' => $qtyToAllocate ,
'subtotal' => $newSubtotal ,
]);
$cartItem -> refresh (); // Ensure model reflects database state
$cartItem -> updateMetaKey ( 'allocated_single_item_id' , $single -> id );
$cartItem -> updateMetaKey ( 'allocated_single_item_name' , $single -> name );
if ( $singleInfo [ 'price_id' ] && $singleInfo [ 'price_id' ] !== $cartItem -> price_id ) {
$cartItem -> update ([ 'price_id' => $singleInfo [ 'price_id' ]]);
}
$firstAllocation = false ;
} else {
// Create a new cart item for the additional quantity
// Get price from the single
$priceModel = $single -> defaultPrice () -> first ();
$singlePrice = $priceModel ? -> getCurrentPrice ( $single -> isOnSale ());
if ( $singlePrice === null && $poolProduct -> hasPrice ()) {
$priceModel = $poolProduct -> defaultPrice () -> first ();
$singlePrice = $priceModel ? -> getCurrentPrice ( $poolProduct -> isOnSale ());
}
$days = $this -> calculateBookingDays ( $from , $until );
$pricePerUnit = ( int ) round ( $singlePrice * $days );
$newCartItem = $this -> items () -> create ([
'purchasable_id' => $cartItem -> purchasable_id ,
'purchasable_type' => $cartItem -> purchasable_type ,
'price_id' => $priceModel ? -> id ,
'quantity' => $qtyToAllocate ,
'price' => $pricePerUnit ,
'regular_price' => $pricePerUnit ,
'unit_amount' => ( int ) round ( $singlePrice ),
'subtotal' => $pricePerUnit * $qtyToAllocate ,
'parameters' => $cartItem -> parameters ,
'from' => $from ,
'until' => $until ,
]);
$newCartItem -> updateMetaKey ( 'allocated_single_item_id' , $single -> id );
$newCartItem -> updateMetaKey ( 'allocated_single_item_name' , $single -> name );
}
$singleUsage [ $single -> id ] = $usedFromSingle + $qtyToAllocate ;
$remainingQty -= $qtyToAllocate ;
$allocated = true ;
}
// If we still have remaining quantity that couldn't be allocated
if ( $remainingQty > 0 ) {
if ( $firstAllocation ) {
// Couldn't allocate anything - mark as unavailable
$cartItem -> updateMetaKey ( 'allocated_single_item_id' , null );
$cartItem -> updateMetaKey ( 'allocated_single_item_name' , null );
$cartItem -> update ([
'price' => null ,
'subtotal' => null ,
'unit_amount' => null ,
]);
} else {
// Partial allocation - the cart item was already updated with what we could allocate
// The remaining quantity is lost (over-capacity)
}
}
2025-12-20 10:22:04 +00:00
}
}
}
}
2025-12-17 11:26:26 +00:00
/**
* Validate that all booking items in the cart are available for the given timespan .
*
* @ param \DateTimeInterface $from Start date
* @ param \DateTimeInterface $until End date
* @ return void
* @ throws NotEnoughAvailableInTimespanException
*/
2025-12-20 11:19:34 +00:00
/**
* Mark booking items as unavailable if they cannot be booked for the given dates .
* Instead of throwing exceptions , this marks items with null price .
*
* @ param \DateTimeInterface $from Start date
* @ param \DateTimeInterface $until End date
* @ param bool $useProvidedDates Whether to use provided dates or item ' s own dates
* @ return void
*/
2025-12-19 11:25:59 +00:00
protected function validateDateAvailability ( \DateTimeInterface $from , \DateTimeInterface $until , bool $useProvidedDates = false ) : void
2025-12-17 11:26:26 +00:00
{
foreach ( $this -> items as $item ) {
if ( ! $item -> is_booking ) {
continue ;
}
$product = $item -> purchasable ;
if ( ! $product ) {
continue ;
}
2025-12-20 11:19:34 +00:00
// Skip pool products - they are handled by reallocatePoolItems()
if ( $product -> type === ProductType :: POOL ) {
continue ;
}
2025-12-19 11:25:59 +00:00
// Use provided dates when validating date overwrites, otherwise use item's specific dates
$checkFrom = $useProvidedDates ? $from : ( $item -> from ? ? $from );
$checkUntil = $useProvidedDates ? $until : ( $item -> until ? ? $until );
2025-12-17 11:26:26 +00:00
if ( ! $product -> isAvailableForBooking ( $checkFrom , $checkUntil , $item -> quantity )) {
2025-12-20 11:19:34 +00:00
// Mark item as unavailable instead of throwing exception
// This allows users to freely adjust dates
$item -> update ([
'price' => null ,
'subtotal' => null ,
'unit_amount' => null ,
]);
2025-12-17 11:26:26 +00:00
}
}
}
2025-12-18 11:21:29 +00:00
/**
* Scope to find abandoned carts
* Carts that are active but haven ' t been updated recently
*/
public function scopeAbandoned ( $query , $inactiveMinutes = 60 )
{
return $query -> where ( 'status' , CartStatus :: ACTIVE )
-> where ( 'last_activity_at' , '<' , now () -> subMinutes ( $inactiveMinutes ));
}
2025-11-29 11:05:02 +00:00
public function getUnpaidAmount () : float
{
$paidAmount = $this -> purchases ()
-> whereColumn ( 'total_amount' , '!=' , 'amount_paid' )
-> sum ( 'total_amount' );
return max ( 0 , $this -> getTotal () - $paidAmount );
}
public function getPaidAmount () : float
{
return $this -> purchases ()
-> whereColumn ( 'total_amount' , '!=' , 'amount_paid' )
-> sum ( 'total_amount' );
}
2025-11-21 10:49:41 +00:00
public function isExpired () : bool
{
return $this -> expires_at && $this -> expires_at -> isPast ();
}
public function isConverted () : bool
{
return ! is_null ( $this -> converted_at );
}
public function scopeActive ( $query )
{
return $query -> whereNull ( 'converted_at' )
-> where ( function ( $q ) {
$q -> whereNull ( 'expires_at' )
-> orWhere ( 'expires_at' , '>' , now ());
});
}
public function scopeForUser ( $query , $userOrId )
{
if ( is_object ( $userOrId )) {
return $query -> where ( 'customer_id' , $userOrId -> id )
-> where ( 'customer_type' , get_class ( $userOrId ));
}
// If just an ID is passed, try to determine the user model class
$userModel = config ( 'auth.providers.users.model' , \Workbench\App\Models\User :: class );
return $query -> where ( 'customer_id' , $userOrId )
-> where ( 'customer_type' , $userModel );
}
2025-11-22 17:09:45 +00:00
2025-11-29 11:05:02 +00:00
public static function scopeUnpaid ( $query )
{
return $query -> whereDoesntHave ( 'purchases' , function ( $q ) {
$q -> whereColumn ( 'total_amount' , '!=' , 'amount_paid' );
});
}
2025-12-17 08:24:42 +00:00
/**
* Store the cart ID in the session for retrieval across requests
*
* @ param Cart $cart
* @ return void
*/
public static function setSession ( Cart $cart ) : void
{
session ([ CartService :: CART_SESSION_KEY => $cart -> id ]);
}
2025-12-09 08:42:59 +00:00
/**
* Add an item to the cart or increase quantity if it already exists .
*
* @ param Model & Cartable $cartable The item to add to cart
* @ param int $quantity The quantity to add
* @ param array < string , mixed > $parameters Additional parameters for the cart item
2025-12-15 11:28:15 +00:00
* @ param \DateTimeInterface | null $from Optional start date for bookings
* @ param \DateTimeInterface | null $until Optional end date for bookings
2025-12-09 08:42:59 +00:00
* @ return CartItem
* @ throws \Exception If the item doesn ' t implement Cartable interface
*/
2025-11-23 14:07:12 +00:00
public function addToCart (
2025-11-29 11:05:02 +00:00
Model $cartable ,
2025-12-09 08:42:59 +00:00
int $quantity = 1 ,
2025-12-15 11:28:15 +00:00
array $parameters = [],
2025-12-24 18:40:10 +00:00
null | \DateTimeInterface $from = null ,
null | \DateTimeInterface $until = null
2025-11-29 11:05:02 +00:00
) : CartItem {
2025-11-23 14:07:12 +00:00
// $cartable must implement Cartable
if ( ! $cartable instanceof Cartable ) {
2025-12-19 13:26:57 +00:00
throw new CartableInterfaceException ();
2025-11-23 14:07:12 +00:00
}
2025-12-15 13:10:59 +00:00
// Extract dates from parameters if not provided directly
if ( ! $from && isset ( $parameters [ 'from' ])) {
$from = is_string ( $parameters [ 'from' ]) ? Carbon :: parse ( $parameters [ 'from' ]) : $parameters [ 'from' ];
}
if ( ! $until && isset ( $parameters [ 'until' ])) {
$until = is_string ( $parameters [ 'until' ]) ? Carbon :: parse ( $parameters [ 'until' ]) : $parameters [ 'until' ];
}
2025-12-20 14:08:08 +00:00
// Fallback to cart dates if no dates provided
if ( ! $from && $this -> from ) {
$from = $this -> from ;
}
if ( ! $until && $this -> until ) {
$until = $this -> until ;
}
2025-12-24 18:40:10 +00:00
if ( $cartable instanceof Product ) {
$is_pool = $cartable -> isPool ();
$is_booking = $cartable -> isBooking ();
} elseif (
$cartable instanceof ProductPrice
&& $cartable -> purchasable instanceof Product
) {
$is_pool = $cartable -> purchasable -> isPool ();
$is_booking = $cartable -> purchasable -> isBooking ();
}
2025-12-16 12:58:03 +00:00
// For pool products with quantity > 1, add them one at a time to get progressive pricing
2025-12-24 18:40:10 +00:00
if ( $is_pool && $quantity > 1 ) {
2025-12-20 10:22:04 +00:00
// Validate availability if dates are provided
2025-12-16 12:58:03 +00:00
if ( $from && $until ) {
$available = $cartable -> getPoolMaxQuantity ( $from , $until );
2025-12-20 10:22:04 +00:00
// Subtract items already in cart for the same period
$itemsInCart = $this -> items ()
-> where ( 'purchasable_id' , $cartable -> getKey ())
-> where ( 'purchasable_type' , get_class ( $cartable ))
-> get ()
-> filter ( function ( $item ) use ( $from , $until ) {
// Only count items with overlapping dates
if ( ! $item -> from || ! $item -> until ) {
return false ;
}
// Check for overlap: item overlaps if it doesn't end before period starts or start after period ends
return ! ( $item -> until < $from || $item -> from > $until );
})
-> sum ( 'quantity' );
$availableForThisRequest = $available === PHP_INT_MAX ? PHP_INT_MAX : max ( 0 , $available - $itemsInCart );
if ( $availableForThisRequest !== PHP_INT_MAX && $quantity > $availableForThisRequest ) {
2025-12-19 13:26:57 +00:00
throw new NotEnoughStockException (
2025-12-20 10:22:04 +00:00
" Pool product ' { $cartable -> name } ' has only { $availableForThisRequest } items available for the requested period. Requested: { $quantity } "
2025-12-16 12:58:03 +00:00
);
}
2025-12-20 11:19:34 +00:00
} else {
// When dates are not provided, validate against total pool capacity (not current availability)
// This allows adding items even if currently claimed - dates will be validated later
$totalCapacity = $cartable -> getPoolTotalCapacity (); // Total capacity ignoring claims
// Subtract items already in cart
$itemsInCart = $this -> items ()
-> where ( 'purchasable_id' , $cartable -> getKey ())
-> where ( 'purchasable_type' , get_class ( $cartable ))
-> sum ( 'quantity' );
2025-12-24 18:40:10 +00:00
$availableForThisRequest = $totalCapacity === PHP_INT_MAX
? PHP_INT_MAX
: max ( 0 , $totalCapacity - $itemsInCart );
2025-12-20 11:19:34 +00:00
if ( $availableForThisRequest !== PHP_INT_MAX && $quantity > $availableForThisRequest ) {
throw new NotEnoughStockException (
" Pool product ' { $cartable -> name } ' has only { $availableForThisRequest } items available. Requested: { $quantity } "
);
}
2025-12-16 12:58:03 +00:00
}
2025-12-17 08:24:42 +00:00
2025-12-16 12:58:03 +00:00
// Add items one at a time for progressive pricing
$lastCartItem = null ;
for ( $i = 0 ; $i < $quantity ; $i ++ ) {
$lastCartItem = $this -> addToCart ( $cartable , 1 , $parameters , $from , $until );
}
return $lastCartItem ;
}
2025-12-15 11:28:15 +00:00
// Validate Product-specific requirements
if ( $cartable instanceof Product ) {
// Validate pricing before adding to cart
$cartable -> validatePricing ( throwExceptions : true );
2025-12-17 11:26:26 +00:00
// Validate dates if both are provided
2025-12-15 11:28:15 +00:00
if ( $from && $until ) {
// Validate from is before until
if ( $from >= $until ) {
2025-12-19 13:26:57 +00:00
throw new InvalidDateRangeException ( " The 'from' date must be before the 'until' date. Got from: { $from -> format ( 'Y-m-d H:i:s' ) } , until: { $until -> format ( 'Y-m-d H:i:s' ) } " );
2025-12-15 11:28:15 +00:00
}
// Check booking product availability if dates are provided
2025-12-24 18:40:10 +00:00
if (
$is_booking
&& ! $is_pool
&& ! $cartable -> isAvailableForBooking ( $from , $until , $quantity )
) {
2025-12-19 13:26:57 +00:00
throw new NotEnoughStockException (
2025-12-15 11:28:15 +00:00
" Product ' { $cartable -> name } ' is not available for the requested period ( { $from -> format ( 'Y-m-d' ) } to { $until -> format ( 'Y-m-d' ) } ). "
);
}
// Check pool product availability if dates are provided
2025-12-24 18:40:10 +00:00
if ( $is_pool ) {
2025-12-15 11:28:15 +00:00
$maxQuantity = $cartable -> getPoolMaxQuantity ( $from , $until );
2025-12-20 10:22:04 +00:00
// Subtract items already in cart for the same period
2025-12-20 14:08:08 +00:00
// Only count items that are actually valid (have a price allocated)
2025-12-20 10:22:04 +00:00
$itemsInCart = $this -> items ()
-> where ( 'purchasable_id' , $cartable -> getKey ())
-> where ( 'purchasable_type' , get_class ( $cartable ))
-> get ()
-> filter ( function ( $item ) use ( $from , $until ) {
2025-12-20 14:08:08 +00:00
// Don't count items marked as unavailable (null price)
if ( $item -> price === null ) {
return false ;
}
2025-12-20 10:22:04 +00:00
// Only count items with overlapping dates
if ( ! $item -> from || ! $item -> until ) {
return false ;
}
// Check for overlap
return ! ( $item -> until < $from || $item -> from > $until );
})
-> sum ( 'quantity' );
$availableForThisRequest = $maxQuantity === PHP_INT_MAX ? PHP_INT_MAX : max ( 0 , $maxQuantity - $itemsInCart );
2025-12-17 11:26:26 +00:00
// Only validate if pool has limited availability AND quantity exceeds it
2025-12-20 10:22:04 +00:00
if ( $availableForThisRequest !== PHP_INT_MAX && $quantity > $availableForThisRequest ) {
2025-12-19 13:26:57 +00:00
throw new NotEnoughStockException (
2025-12-20 10:22:04 +00:00
" Pool product ' { $cartable -> name } ' has only { $availableForThisRequest } items available for the requested period ( { $from -> format ( 'Y-m-d' ) } to { $until -> format ( 'Y-m-d' ) } ). Requested: { $quantity } "
2025-12-15 11:28:15 +00:00
);
}
}
} elseif ( $from || $until ) {
// If only one date is provided, it's an error
2025-12-19 13:26:57 +00:00
throw new CartDatesRequiredException ();
2025-12-15 13:10:59 +00:00
} else {
2025-12-20 11:19:34 +00:00
// When adding pool items without dates, validate against total pool capacity
// This allows adding items even if currently claimed - date-based validation happens later
2025-12-24 18:40:10 +00:00
if ( $is_pool ) {
2025-12-20 11:19:34 +00:00
$totalCapacity = $cartable -> getPoolTotalCapacity (); // Total capacity ignoring claims
2025-12-15 13:10:59 +00:00
2025-12-20 11:19:34 +00:00
// Subtract items already in cart (without dates or with any dates)
$itemsInCart = $this -> items ()
-> where ( 'purchasable_id' , $cartable -> getKey ())
-> where ( 'purchasable_type' , get_class ( $cartable ))
-> sum ( 'quantity' );
$availableForThisRequest = $totalCapacity === PHP_INT_MAX ? PHP_INT_MAX : max ( 0 , $totalCapacity - $itemsInCart );
if ( $availableForThisRequest !== PHP_INT_MAX && $quantity > $availableForThisRequest ) {
throw new NotEnoughStockException (
" Pool product ' { $cartable -> name } ' has only { $availableForThisRequest } items available. Requested: { $quantity } "
);
}
}
// Items may be claimed now but available in the future
// Full date-based validation will happen when dates are set via setDates() or at checkout
2025-12-15 11:28:15 +00:00
}
}
2025-12-16 12:58:03 +00:00
// For pool products, calculate current quantity in cart once to ensure consistency
// Force fresh query to get latest cart state (important for recursive calls)
$currentQuantityInCart = null ;
2025-12-19 09:57:26 +00:00
$poolSingleItem = null ;
$poolPriceId = null ;
2025-12-24 18:40:10 +00:00
if ( $is_pool ) {
2025-12-16 12:58:03 +00:00
$this -> unsetRelation ( 'items' ); // Clear cached relationship
$currentQuantityInCart = $this -> items ()
-> where ( 'purchasable_id' , $cartable -> getKey ())
-> where ( 'purchasable_type' , get_class ( $cartable ))
-> sum ( 'quantity' );
2025-12-19 09:57:26 +00:00
// Pre-calculate pool pricing info for use in merge logic
$poolItemData = $cartable -> getNextAvailablePoolItemWithPrice ( $this , null , $from , $until );
if ( $poolItemData ) {
$poolSingleItem = $poolItemData [ 'item' ];
$poolPriceId = $poolItemData [ 'price_id' ];
}
2025-12-16 12:58:03 +00:00
}
2025-12-15 13:10:59 +00:00
// Check if item already exists in cart with same parameters, dates, AND price
2025-12-09 08:42:59 +00:00
$existingItem = $this -> items ()
-> where ( 'purchasable_id' , $cartable -> getKey ())
-> where ( 'purchasable_type' , get_class ( $cartable ))
-> get ()
2025-12-24 18:40:10 +00:00
-> first ( function ( $item ) use ( $parameters , $from , $until , $cartable , $poolPriceId , $is_pool ) {
2025-12-09 08:42:59 +00:00
$existingParams = is_array ( $item -> parameters )
? $item -> parameters
: ( array ) $item -> parameters ;
// Sort both arrays to ensure consistent comparison
ksort ( $existingParams );
ksort ( $parameters );
2025-12-15 11:28:15 +00:00
// Check parameters match
$paramsMatch = $existingParams === $parameters ;
// Check dates match (important for bookings)
$datesMatch = true ;
if ( $from || $until ) {
$datesMatch = (
( $item -> from ? -> format ( 'Y-m-d H:i:s' ) === $from ? -> format ( 'Y-m-d H:i:s' )) &&
( $item -> until ? -> format ( 'Y-m-d H:i:s' ) === $until ? -> format ( 'Y-m-d H:i:s' ))
);
}
2025-12-19 11:47:55 +00:00
// For pool products, check if we should merge with existing items
// Pool items can ONLY merge if they are from the SAME single item
// This is critical because different single items have their own stock limits
// even if they happen to share the same price (e.g., via pool fallback price)
2025-12-15 13:10:59 +00:00
$priceMatch = true ;
2025-12-24 18:40:10 +00:00
if ( $is_pool ) {
2025-12-19 09:57:26 +00:00
// Calculate expected price for this item
$poolItemData = $cartable -> getNextAvailablePoolItemWithPrice ( $this , null , $from , $until );
$expectedPrice = $poolItemData [ 'price' ] ? ? null ;
2025-12-19 11:47:55 +00:00
$expectedSingleItemId = $poolItemData [ 'item' ] ? -> id ? ? null ;
2025-12-19 11:25:59 +00:00
2025-12-19 11:47:55 +00:00
// Get the allocated single item ID from the existing cart item's meta
$existingMeta = $item -> getMeta ();
$existingAllocatedItemId = $existingMeta -> allocated_single_item_id ? ? null ;
// Only merge if:
// 1. price_id matches (same price source)
// 2. actual price amount matches
// 3. allocated single item matches (CRITICAL: same single item being used)
2025-12-19 11:25:59 +00:00
$priceMatch = $poolPriceId && $item -> price_id === $poolPriceId &&
2025-12-19 11:47:55 +00:00
$expectedPrice !== null && $item -> unit_amount === ( int ) round ( $expectedPrice ) &&
$expectedSingleItemId !== null && $existingAllocatedItemId === $expectedSingleItemId ;
2025-12-15 13:10:59 +00:00
}
return $paramsMatch && $datesMatch && $priceMatch ;
2025-12-09 08:42:59 +00:00
});
2025-12-15 11:28:15 +00:00
// Calculate price per day (base price)
2025-12-16 12:58:03 +00:00
// For pool products, get price based on how many items are already in cart
2025-12-24 18:40:10 +00:00
if ( $is_pool ) {
2025-12-17 09:41:52 +00:00
// Use smarter pricing that considers which price tiers are used
2025-12-17 17:33:34 +00:00
$poolItemData = $cartable -> getNextAvailablePoolItemWithPrice ( $this , null , $from , $until );
if ( $poolItemData ) {
$pricePerDay = $poolItemData [ 'price' ];
$poolSingleItem = $poolItemData [ 'item' ];
$poolPriceId = $poolItemData [ 'price_id' ];
} else {
$pricePerDay = null ;
}
// Get regular price (non-sale) for comparison
$regularPoolItemData = $cartable -> getNextAvailablePoolItemWithPrice ( $this , false , $from , $until );
$regularPricePerDay = $regularPoolItemData [ 'price' ] ? ? $pricePerDay ;
2025-12-17 08:24:42 +00:00
2025-12-16 12:58:03 +00:00
// If no price found from pool items, try the pool's direct price as fallback
if ( $pricePerDay === null && $cartable -> hasPrice ()) {
2025-12-17 17:33:34 +00:00
$priceModel = $cartable -> defaultPrice () -> first ();
$pricePerDay = $priceModel ? -> getCurrentPrice ( $cartable -> isOnSale ());
$regularPricePerDay = $priceModel ? -> getCurrentPrice ( false ) ? ? $pricePerDay ;
$poolPriceId = $priceModel ? -> id ;
2025-12-16 12:58:03 +00:00
}
} else {
$pricePerDay = $cartable -> getCurrentPrice ();
$regularPricePerDay = $cartable -> getCurrentPrice ( false ) ? ? $pricePerDay ;
}
2025-12-15 11:28:15 +00:00
// Ensure prices are not null
if ( $pricePerDay === null ) {
2025-12-24 18:40:10 +00:00
if ( $is_pool ) {
2025-12-17 15:43:22 +00:00
// For pool products, throw specific error when neither pool nor single items have prices
throw \Blax\Shop\Exceptions\HasNoPriceException :: poolProductNoPriceAndNoSingleItemPrices ( $cartable -> name );
2025-12-16 12:58:03 +00:00
}
2025-12-19 13:26:57 +00:00
throw new ProductHasNoPriceException ( $cartable -> name );
2025-12-15 11:28:15 +00:00
}
// Calculate days if booking dates provided
$days = 1 ;
if ( $from && $until ) {
2025-12-17 16:57:17 +00:00
$days = $this -> calculateBookingDays ( $from , $until );
2025-12-15 11:28:15 +00:00
}
2025-12-18 09:54:42 +00:00
// Calculate price per unit for the entire period and round to nearest cent for consistency
2025-12-24 18:40:10 +00:00
if ( $is_booking ) {
// For bookings, price scales with days
$pricePerUnit = ( int ) round ( $pricePerDay * $days );
$regularPricePerUnit = ( int ) round ( $regularPricePerDay * $days );
} else {
// For non-bookings, price is per unit regardless of days
$pricePerUnit = ( int ) round ( $pricePerDay );
$regularPricePerUnit = ( int ) round ( $regularPricePerDay );
}
2025-12-15 11:28:15 +00:00
2025-12-16 12:58:03 +00:00
// Defensive check - ensure pricePerUnit is not null
if ( $pricePerUnit === null ) {
2025-12-19 13:26:57 +00:00
throw new PriceCalculationException ( $cartable -> name , $pricePerDay , $days );
2025-12-16 12:58:03 +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-15 11:28:15 +00:00
// Calculate total price
$totalPrice = $pricePerUnit * $quantity ;
2025-12-09 08:42:59 +00:00
if ( $existingItem ) {
// Update quantity and subtotal
$newQuantity = $existingItem -> quantity + $quantity ;
$existingItem -> update ([
'quantity' => $newQuantity ,
2025-12-15 11:28:15 +00:00
'subtotal' => $pricePerUnit * $newQuantity ,
2025-12-09 08:42:59 +00:00
]);
return $existingItem -> fresh ();
}
2025-12-17 11:26:26 +00:00
// Determine price_id for the cart item
$priceId = null ;
if ( $cartable instanceof Product ) {
2025-12-17 17:33:34 +00:00
// For pool products, use the single item's price_id
2025-12-24 18:40:10 +00:00
if ( $is_pool && $poolPriceId ) {
2025-12-17 17:33:34 +00:00
$priceId = $poolPriceId ;
} else {
// Get the default price for the product
$defaultPrice = $cartable -> defaultPrice () -> first ();
$priceId = $defaultPrice ? -> id ;
}
2025-12-17 11:26:26 +00:00
} elseif ( $cartable instanceof \Blax\Shop\Models\ProductPrice ) {
// If adding a ProductPrice directly, use its ID
$priceId = $cartable -> id ;
}
2025-12-09 08:42:59 +00:00
// Create new cart item
2025-11-23 14:07:12 +00:00
$cartItem = $this -> items () -> create ([
'purchasable_id' => $cartable -> getKey (),
'purchasable_type' => get_class ( $cartable ),
2025-12-17 11:26:26 +00:00
'price_id' => $priceId ,
2025-11-23 14:07:12 +00:00
'quantity' => $quantity ,
2025-12-15 11:28:15 +00:00
'price' => $pricePerUnit , // Price per unit for the period
'regular_price' => $regularPricePerUnit ,
2025-12-18 11:21:29 +00:00
'unit_amount' => $unitAmount , // Base price for 1 quantity, 1 day (in cents)
2025-12-15 11:28:15 +00:00
'subtotal' => $totalPrice , // Total for all units
2025-11-23 14:07:12 +00:00
'parameters' => $parameters ,
2025-12-15 11:28:15 +00:00
'from' => $from ,
'until' => $until ,
2025-11-23 14:07:12 +00:00
]);
2025-12-17 17:33:34 +00:00
// For pool products, store which single item is being used in meta
if ( $cartable instanceof Product && $cartable -> isPool () && $poolSingleItem ) {
$cartItem -> updateMetaKey ( 'allocated_single_item_id' , $poolSingleItem -> id );
$cartItem -> updateMetaKey ( 'allocated_single_item_name' , $poolSingleItem -> name );
}
2025-12-16 12:58:03 +00:00
return $cartItem ;
2025-11-23 14:07:12 +00:00
}
2025-11-29 11:05:02 +00:00
2025-12-09 09:30:53 +00:00
public function removeFromCart (
Model $cartable ,
int $quantity = 1 ,
array $parameters = []
) : CartItem | true {
2025-12-17 09:41:52 +00:00
// If a CartItem is passed directly, handle it
if ( $cartable instanceof CartItem ) {
$item = $cartable ;
if ( $item -> quantity > $quantity ) {
// Decrease quantity
$newQuantity = $item -> quantity - $quantity ;
$item -> update ([
'quantity' => $newQuantity ,
'subtotal' => $item -> price * $newQuantity ,
]);
} else {
// Remove item from cart
$item -> delete ();
}
return $item ;
}
// Otherwise, find the cart item by purchasable
$items = $this -> items ()
2025-12-09 09:30:53 +00:00
-> where ( 'purchasable_id' , $cartable -> getKey ())
-> where ( 'purchasable_type' , get_class ( $cartable ))
-> get ()
2025-12-17 09:41:52 +00:00
-> filter ( function ( $item ) use ( $parameters ) {
2025-12-09 09:30:53 +00:00
$existingParams = is_array ( $item -> parameters )
? $item -> parameters
: ( array ) $item -> parameters ;
ksort ( $existingParams );
ksort ( $parameters );
return $existingParams === $parameters ;
});
2025-12-17 09:41:52 +00:00
if ( $items -> isEmpty ()) {
return true ;
}
// For pool products with multiple cart items at different prices,
// remove from the highest-priced item first (LIFO behavior)
$item = $items -> sortByDesc ( 'price' ) -> first ();
2025-12-09 09:30:53 +00:00
if ( $item ) {
if ( $item -> quantity > $quantity ) {
// Decrease quantity
$newQuantity = $item -> quantity - $quantity ;
$item -> update ([
'quantity' => $newQuantity ,
2025-12-17 09:41:52 +00:00
'subtotal' => $item -> price * $newQuantity ,
2025-12-09 09:30:53 +00:00
]);
} else {
// Remove item from cart
$item -> delete ();
}
}
return $item ? ? true ;
}
2025-12-15 13:10:59 +00:00
/**
* Validate cart for checkout without converting it
*
2025-12-19 12:32:00 +00:00
* Checks :
* 1. Cart is not already converted
* 2. Cart is not empty
* 3. All items have required information
* 4. Stock is available for all items ( for booking / pool products with dates )
*
2025-12-15 13:10:59 +00:00
* @ throws \Exception
*/
2025-12-18 14:33:47 +00:00
public function validateForCheckout ( bool $throws = true ) : bool
2025-11-29 11:05:02 +00:00
{
2025-12-19 12:32:00 +00:00
// Check if cart is already converted
if ( $this -> isConverted ()) {
if ( $throws ) {
2025-12-19 13:26:57 +00:00
throw new CartAlreadyConvertedException ();
2025-12-19 12:32:00 +00:00
} else {
return false ;
}
}
2025-11-29 11:05:02 +00:00
$items = $this -> items ()
-> with ( 'purchasable' )
-> get ();
if ( $items -> isEmpty ()) {
2025-12-18 14:33:47 +00:00
if ( $throws ) {
2025-12-19 13:26:57 +00:00
throw new CartEmptyException ();
2025-12-18 14:33:47 +00:00
} else {
return false ;
}
2025-11-29 11:05:02 +00:00
}
2025-12-15 13:10:59 +00:00
// Validate that all items have required information before checkout
foreach ( $items as $item ) {
$adjustments = $item -> requiredAdjustments ();
if ( ! empty ( $adjustments )) {
$product = $item -> purchasable ;
$productName = $product ? $product -> name : 'Unknown Product' ;
$missingFields = implode ( ', ' , array_keys ( $adjustments ));
2025-12-18 14:33:47 +00:00
if ( $throws ) {
2025-12-19 13:26:57 +00:00
throw new CartItemMissingInformationException ( $productName , $missingFields );
2025-12-18 14:33:47 +00:00
} else {
return false ;
}
2025-12-15 13:10:59 +00:00
}
}
2025-12-18 14:33:47 +00:00
2025-12-19 12:32:00 +00:00
// Validate stock availability for all items
foreach ( $items as $item ) {
$product = $item -> purchasable ;
if ( ! ( $product instanceof Product )) {
continue ;
}
2025-12-19 13:26:57 +00:00
// Use effective dates (item-specific or cart fallback)
$from = $item -> getEffectiveFromDate ();
$until = $item -> getEffectiveUntilDate ();
2025-12-19 12:32:00 +00:00
// For pool products, check pool availability
if ( $product -> isPool ()) {
if ( $from && $until ) {
// Get available quantity considering existing cart items and pending purchases
$available = $product -> getPoolMaxQuantity ( $from , $until );
// Calculate how much of this cart's items are already counted
// We need to check if there's still enough stock for what's in this cart
$cartItemsForPool = $items -> filter (
fn ( $i ) =>
$i -> purchasable_id === $product -> id &&
$i -> purchasable_type === get_class ( $product )
);
$totalInCart = $cartItemsForPool -> sum ( 'quantity' );
if ( $available !== PHP_INT_MAX && $totalInCart > $available ) {
if ( $throws ) {
2025-12-19 13:26:57 +00:00
throw new NotEnoughStockException (
2025-12-19 12:32:00 +00:00
" Pool product ' { $product -> name } ' has only { $available } items available for the period " .
" { $from -> format ( 'Y-m-d' ) } to { $until -> format ( 'Y-m-d' ) } . Cart has: { $totalInCart } "
);
} else {
return false ;
}
}
} else {
// Without dates, check general pool availability
$available = $product -> getPoolMaxQuantity ();
$totalInCart = $items -> filter (
fn ( $i ) =>
$i -> purchasable_id === $product -> id &&
$i -> purchasable_type === get_class ( $product )
) -> sum ( 'quantity' );
if ( $available !== PHP_INT_MAX && $totalInCart > $available ) {
if ( $throws ) {
2025-12-19 13:26:57 +00:00
throw new NotEnoughStockException (
2025-12-19 12:32:00 +00:00
" Pool product ' { $product -> name } ' has only { $available } items available. Cart has: { $totalInCart } "
);
} else {
return false ;
}
}
}
} elseif ( $product -> isBooking () && $product -> manage_stock ) {
// For booking products with managed stock
if ( $from && $until ) {
if ( ! $product -> isAvailableForBooking ( $from , $until , $item -> quantity )) {
if ( $throws ) {
2025-12-19 13:26:57 +00:00
throw new NotEnoughStockException (
2025-12-19 12:32:00 +00:00
" Booking product ' { $product -> name } ' is not available for the period " .
" { $from -> format ( 'Y-m-d' ) } to { $until -> format ( 'Y-m-d' ) } . Requested: { $item -> quantity } "
);
} else {
return false ;
}
}
}
} elseif ( $product -> manage_stock ) {
// For regular products with managed stock
$available = $product -> getAvailableStock ();
if ( $item -> quantity > $available ) {
if ( $throws ) {
2025-12-19 13:26:57 +00:00
throw new NotEnoughStockException (
2025-12-19 12:32:00 +00:00
" Product ' { $product -> name } ' has only { $available } items in stock. Requested: { $item -> quantity } "
);
} else {
return false ;
}
}
}
}
2025-12-18 14:33:47 +00:00
return true ;
2025-12-15 13:10:59 +00:00
}
public function checkout () : static
{
2025-12-19 08:53:44 +00:00
return DB :: transaction ( function () {
2025-12-18 11:21:29 +00:00
// Lock the cart to prevent concurrent checkouts
$this -> lockForUpdate ();
2025-12-15 13:10:59 +00:00
2025-12-18 11:21:29 +00:00
// Validate cart before proceeding
$this -> validateForCheckout ();
2025-12-15 13:10:59 +00:00
2025-12-18 11:21:29 +00:00
$items = $this -> items ()
-> with ( 'purchasable' )
-> get ();
2025-12-09 08:42:59 +00:00
2025-12-18 11:21:29 +00:00
// Create ProductPurchase for each cart item
foreach ( $items as $item ) {
$product = $item -> purchasable ;
2025-12-15 10:32:31 +00:00
2025-12-18 11:21:29 +00:00
// Lock the product to prevent race conditions on stock
if ( $product instanceof Product && method_exists ( $product , 'lockForUpdate' )) {
$product = $product -> lockForUpdate () -> find ( $product -> id );
}
2025-12-15 10:32:31 +00:00
2025-12-18 11:21:29 +00:00
$quantity = $item -> quantity ;
// Get booking dates from cart item directly (preferred) or from parameters (legacy)
$from = $item -> from ;
$until = $item -> until ;
if ( ! $from || ! $until ) {
if (( $product -> type === ProductType :: BOOKING || $product -> type === ProductType :: POOL ) && $item -> parameters ) {
$params = is_array ( $item -> parameters ) ? $item -> parameters : ( array ) $item -> parameters ;
$from = $params [ 'from' ] ? ? null ;
$until = $params [ 'until' ] ? ? null ;
// Convert to Carbon instances if they're strings
if ( $from && is_string ( $from )) {
$from = \Carbon\Carbon :: parse ( $from );
}
if ( $until && is_string ( $until )) {
$until = \Carbon\Carbon :: parse ( $until );
}
2025-12-15 10:32:31 +00:00
}
2025-12-03 12:59:01 +00:00
}
2025-12-15 10:32:31 +00:00
2025-12-18 11:21:29 +00:00
// Handle pool products with booking single items
if ( $product instanceof Product && $product -> isPool ()) {
// Check if pool with booking items requires timespan
if ( $product -> hasBookingSingleItems () && ( ! $from || ! $until )) {
throw new \Exception ( " Pool product ' { $product -> name } ' with booking items requires a timespan (from/until dates). " );
}
2025-12-15 10:32:31 +00:00
2025-12-18 11:21:29 +00:00
// If pool has timespan and has booking single items, claim stock from single items
if ( $from && $until && $product -> hasBookingSingleItems ()) {
try {
2025-12-20 10:22:04 +00:00
// Check if we have pre-allocated single items from reallocation
$meta = $item -> getMeta ();
$allocatedSingleId = $meta -> allocated_single_item_id ? ? null ;
if ( $allocatedSingleId ) {
// Use the pre-allocated single item
$singleItem = Product :: find ( $allocatedSingleId );
if ( ! $singleItem ) {
throw new \Exception ( " Allocated single item not found: { $allocatedSingleId } " );
}
// Claim stock for this specific item
$singleItem -> claimStock ( $quantity , $this , $from , $until , " Checkout from cart { $this -> id } " );
$claimedItems = [ $singleItem ];
} else {
// No pre-allocation, use standard pool claiming logic
$claimedItems = $product -> claimPoolStock (
$quantity ,
$this ,
$from ,
$until ,
" Checkout from cart { $this -> id } "
);
}
2025-12-15 10:32:31 +00:00
2025-12-18 11:21:29 +00:00
// Store claimed items info in purchase meta
$item -> updateMetaKey ( 'claimed_single_items' , array_map ( fn ( $i ) => $i -> id , $claimedItems ));
$item -> save ();
} catch ( \Exception $e ) {
throw new \Exception ( " Failed to checkout pool product ' { $product -> name } ': " . $e -> getMessage ());
}
2025-12-15 10:32:31 +00:00
}
2025-12-03 12:59:01 +00:00
}
2025-11-29 11:05:02 +00:00
2025-12-18 11:21:29 +00:00
// Validate booking products have required dates
2025-12-18 15:54:33 +00:00
if ( $product instanceof Product && $product -> isBooking () && ! $product -> isPool () && ( ! $from || ! $until )) {
2025-12-18 11:21:29 +00:00
throw new \Exception ( " Booking product ' { $product -> name } ' requires a timespan (from/until dates). " );
}
2025-12-15 10:32:31 +00:00
2025-12-18 11:21:29 +00:00
$purchase = $this -> customer -> purchase (
$product -> prices () -> first (),
$quantity ,
null ,
$from ,
$until
);
2025-11-29 11:05:02 +00:00
2025-12-18 11:21:29 +00:00
$purchase -> update ([
'cart_id' => $item -> cart_id ,
]);
2025-11-29 11:05:02 +00:00
2025-12-18 11:21:29 +00:00
// Remove item from cart
$item -> update ([
'purchase_id' => $purchase -> id ,
]);
}
2025-11-29 11:05:02 +00:00
2025-12-18 11:21:29 +00:00
$this -> update ([
'converted_at' => now (),
]);
2025-11-29 11:05:02 +00:00
2025-12-18 11:21:29 +00:00
return $this ;
});
2025-11-29 11:05:02 +00:00
}
2025-12-15 13:10:59 +00:00
/**
* Create a Stripe Checkout Session for this cart
*
* This method :
* - Validates the cart ( doesn ' t convert it )
2025-12-19 08:53:44 +00:00
* - Creates ProductPurchase records for each cart item ( with PENDING status )
2025-12-17 17:33:34 +00:00
* - Uses dynamic price_data for each cart item ( no pre - created Stripe prices needed )
2025-12-15 13:10:59 +00:00
* - Creates line items with descriptions including booking dates
* - Returns the Stripe checkout session
*
* @ param array $options Optional session parameters ( success_url , cancel_url , etc . )
2025-12-18 14:33:47 +00:00
* @ param string | null $url Optional fullPath URL for success and cancel URLs
*
2025-12-17 17:33:34 +00:00
* @ return mixed Stripe\Checkout\Session instance
2025-12-15 13:10:59 +00:00
* @ throws \Exception
*/
2025-12-18 14:33:47 +00:00
public function checkoutSession ( array $options = [], ? string $url = null )
2025-12-15 13:10:59 +00:00
{
if ( ! config ( 'shop.stripe.enabled' )) {
throw new \Exception ( 'Stripe is not enabled' );
}
// Ensure Stripe is initialized
\Stripe\Stripe :: setApiKey ( config ( 'services.stripe.secret' ));
// Validate cart before proceeding (doesn't convert it)
$this -> validateForCheckout ();
2025-12-19 08:53:44 +00:00
// Create ProductPurchase records for each cart item
DB :: transaction ( function () {
foreach ( $this -> items as $item ) {
// Skip if purchase already exists
if ( $item -> purchase_id ) {
continue ;
}
$product = $item -> purchasable ;
$from = $item -> from ;
$until = $item -> until ;
// Create purchase record with PENDING status
$purchase = ProductPurchase :: create ([
'cart_id' => $this -> id ,
'price_id' => $item -> price_id ,
'purchasable_id' => $product -> id ,
'purchasable_type' => get_class ( $product ),
'purchaser_id' => $this -> customer_id ,
'purchaser_type' => $this -> customer_type ,
'quantity' => $item -> quantity ,
'amount' => $item -> subtotal ,
'amount_paid' => 0 ,
'status' => PurchaseStatus :: PENDING ,
'from' => $from ,
'until' => $until ,
'meta' => $item -> meta ,
]);
// Link purchase to cart item
$item -> update ([ 'purchase_id' => $purchase -> id ]);
}
});
2025-12-17 11:26:26 +00:00
$lineItems = [];
2025-12-15 13:10:59 +00:00
2025-12-17 17:33:34 +00:00
foreach ( $this -> items as $item ) {
$product = $item -> purchasable ;
2025-12-15 13:10:59 +00:00
2025-12-17 17:33:34 +00:00
// Get product name (use short_description if available, otherwise name)
$productName = $product -> short_description ? ? $product -> name ? ? 'Product' ;
2025-12-15 13:10:59 +00:00
2025-12-17 17:33:34 +00:00
// Build description with booking dates if available
2025-12-15 13:10:59 +00:00
if ( $item -> from && $item -> until ) {
2025-12-17 16:57:17 +00:00
$fromFormatted = $item -> from -> format ( 'M j, Y H:i' );
$untilFormatted = $item -> until -> format ( 'M j, Y H:i' );
2025-12-17 17:33:34 +00:00
$productName .= " from { $fromFormatted } to { $untilFormatted } " ;
2025-12-15 13:10:59 +00:00
}
2025-12-18 09:54:42 +00:00
// Price is already stored in cents, Stripe expects smallest currency unit
$unitAmountCents = ( int ) $item -> price ;
2025-12-17 17:33:34 +00:00
// Build line item using price_data for dynamic pricing
$lineItem = [
'price_data' => [
'currency' => config ( 'shop.currency' , 'usd' ),
'product_data' => [
'name' => $productName ,
],
'unit_amount' => $unitAmountCents ,
],
'quantity' => $item -> quantity ,
];
2025-12-15 13:10:59 +00:00
$lineItems [] = $lineItem ;
}
2025-12-18 14:33:47 +00:00
$success_url = $url ? ? $options [ 'success_url' ] ? ? route ( 'shop.stripe.success' );
$cancel_url = $url ? ? $options [ 'cancel_url' ] ? ? route ( 'shop.stripe.cancel' );
$success_url = ( strpos ( $success_url , '?' ))
? $success_url . '&session_id={CHECKOUT_SESSION_ID}&cart_id=' . $this -> id
: $success_url . '?session_id={CHECKOUT_SESSION_ID}&cart_id=' . $this -> id ;
$cancel_url = ( strpos ( $cancel_url , '?' ))
? $cancel_url . '&cart_id=' . $this -> id
: $cancel_url . '?cart_id=' . $this -> id ;
2025-12-15 13:10:59 +00:00
// Prepare session parameters
$sessionParams = [
'payment_method_types' => [ 'card' ],
'line_items' => $lineItems ,
'mode' => 'payment' ,
2025-12-18 14:33:47 +00:00
'success_url' => $success_url ,
'cancel_url' => $cancel_url ,
2025-12-15 13:10:59 +00:00
'client_reference_id' => $this -> id ,
'metadata' => array_merge ([
'cart_id' => $this -> id ,
], $options [ 'metadata' ] ? ? []),
];
// Add customer email if available
if ( $this -> customer ) {
if ( method_exists ( $this -> customer , 'email' )) {
$sessionParams [ 'customer_email' ] = $this -> customer -> email ;
} elseif ( isset ( $this -> customer -> email )) {
$sessionParams [ 'customer_email' ] = $this -> customer -> email ;
}
}
// Allow custom session parameters
if ( isset ( $options [ 'session_params' ])) {
$sessionParams = array_merge ( $sessionParams , $options [ 'session_params' ]);
}
try {
$session = \Stripe\Checkout\Session :: create ( $sessionParams );
// Store session ID in cart meta
$meta = $this -> meta ? ? ( object )[];
if ( is_array ( $meta )) {
$meta = ( object ) $meta ;
}
$meta -> stripe_session_id = $session -> id ;
$this -> update ([ 'meta' => $meta ]);
\Illuminate\Support\Facades\Log :: info ( 'Stripe checkout session created' , [
'cart_id' => $this -> id ,
'session_id' => $session -> id ,
]);
return $session ;
} catch ( \Exception $e ) {
\Illuminate\Support\Facades\Log :: error ( 'Stripe checkout session creation failed' , [
'cart_id' => $this -> id ,
'error' => $e -> getMessage (),
]);
throw $e ;
}
}
2025-12-18 11:21:29 +00:00
/**
* Get the checkout session link for this cart .
*
* This method returns :
* - string : The checkout session URL if a session exists and is valid
* - null : If no session exists or Stripe is not enabled
* - false : If an error occurred while retrieving the session
*
* @ return string | null | false
*/
2025-12-18 14:33:47 +00:00
public function checkoutSessionLink ( array $option = [], ? string $url = null ) : string | null | false
2025-12-18 11:21:29 +00:00
{
2025-12-20 11:19:34 +00:00
// Validate cart - throw exceptions if validation fails
// This ensures users know what's wrong instead of silently returning null
$this -> validateForCheckout ();
2025-12-18 11:21:29 +00:00
2025-12-18 14:33:47 +00:00
$checkoutSession = $this -> checkoutSession ( $option , $url );
2025-12-18 11:21:29 +00:00
2025-12-18 14:33:47 +00:00
if ( $checkoutSession ) {
if (
isset ( $checkoutSession -> url )
&& ! empty ( $checkoutSession -> url )
) {
return $checkoutSession -> url ;
2025-12-18 11:21:29 +00:00
}
return false ;
}
2025-12-18 14:33:47 +00:00
return null ;
2025-12-18 11:21:29 +00:00
}
2025-11-21 10:49:41 +00:00
}