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-17 11:26:26 +00:00
use Blax\Shop\Exceptions\InvalidDateRangeException ;
use Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException ;
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 (
\DateTimeInterface | string | int | float $from ,
\DateTimeInterface | string | int | float $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-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-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-17 11:26:26 +00:00
if ( $from >= $until ) {
throw new InvalidDateRangeException ();
}
if ( $validateAvailability ) {
$this -> validateDateAvailability ( $from , $until );
}
2025-12-18 14:33:47 +00:00
// Update cart with from/until
2025-12-17 11:26:26 +00:00
$this -> update ([
2025-12-19 08:53:44 +00:00
'from' => $from ,
'until' => $until ,
2025-12-17 11:26:26 +00:00
]);
2025-12-18 14:33:47 +00:00
// Update cart items with from/until
2025-12-19 09:57:26 +00:00
$this -> applyDatesToItems (
$validateAvailability ,
$overwrite_item_dates
);
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 InvalidDateRangeException
* @ 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 08:53:44 +00:00
if ( $this -> until && $from >= $this -> until ) {
2025-12-17 11:26:26 +00:00
throw new InvalidDateRangeException ();
}
2025-12-19 08:53:44 +00:00
if ( $validateAvailability && $this -> until ) {
$this -> validateDateAvailability ( $from , $this -> until );
2025-12-17 11:26:26 +00:00
}
2025-12-19 08:53:44 +00:00
$this -> update ([ 'from' => $from ]);
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 InvalidDateRangeException
* @ 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 08:53:44 +00:00
if ( $this -> from && $this -> from >= $until ) {
2025-12-17 11:26:26 +00:00
throw new InvalidDateRangeException ();
}
2025-12-19 08:53:44 +00:00
if ( $validateAvailability && $this -> from ) {
$this -> validateDateAvailability ( $this -> from , $until );
2025-12-17 11:26:26 +00:00
}
2025-12-19 08:53:44 +00:00
$this -> update ([ 'until' => $until ]);
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-17 11:26:26 +00:00
* @ return $this
* @ throws NotEnoughAvailableInTimespanException
*/
2025-12-19 09:08:24 +00:00
public function applyDatesToItems ( bool $validateAvailability = true , bool $overwrite = false ) : self
2025-12-17 11:26:26 +00:00
{
2025-12-19 08:53:44 +00:00
if ( ! $this -> from || ! $this -> until ) {
2025-12-17 11:26:26 +00:00
return $this ;
}
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 ;
}
$fromDate = $shouldApplyFrom ? $this -> from : $item -> from ;
$untilDate = $shouldApplyUntil ? $this -> until : $item -> until ;
2025-12-17 11:26:26 +00:00
if ( $validateAvailability ) {
$product = $item -> purchasable ;
2025-12-19 09:08:24 +00:00
if ( $product && ! $product -> isAvailableForBooking ( $fromDate , $untilDate , $item -> quantity )) {
2025-12-17 11:26:26 +00:00
throw new NotEnoughAvailableInTimespanException (
productName : $product -> name ? ? 'Product' ,
requested : $item -> quantity ,
available : 0 , // Could calculate actual available amount
2025-12-19 09:08:24 +00:00
from : $fromDate ,
until : $untilDate
2025-12-17 11:26:26 +00:00
);
}
}
2025-12-19 09:08:24 +00:00
$item -> updateDates ( $fromDate , $untilDate );
2025-12-17 11:26:26 +00:00
}
}
return $this -> fresh ();
}
/**
* 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
*/
protected function validateDateAvailability ( \DateTimeInterface $from , \DateTimeInterface $until ) : void
{
foreach ( $this -> items as $item ) {
if ( ! $item -> is_booking ) {
continue ;
}
$product = $item -> purchasable ;
if ( ! $product ) {
continue ;
}
// Use item's specific dates if set, otherwise use the dates being validated
$checkFrom = $item -> from ? ? $from ;
$checkUntil = $item -> until ? ? $until ;
if ( ! $product -> isAvailableForBooking ( $checkFrom , $checkUntil , $item -> quantity )) {
throw new NotEnoughAvailableInTimespanException (
productName : $product -> name ? ? 'Product' ,
requested : $item -> quantity ,
available : 0 , // Could calculate actual available amount
from : $checkFrom ,
until : $checkUntil
);
}
}
}
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 = [],
\DateTimeInterface $from = 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 ) {
throw new \Exception ( " Item must implement the Cartable interface. " );
}
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-16 12:58:03 +00:00
// For pool products with quantity > 1, add them one at a time to get progressive pricing
if ( $cartable instanceof Product && $cartable -> isPool () && $quantity > 1 ) {
// Pre-validate that we have enough total availability
// This prevents creating partial batches when stock is insufficient
if ( $from && $until ) {
$available = $cartable -> getPoolMaxQuantity ( $from , $until );
if ( $available !== PHP_INT_MAX && $quantity > $available ) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException (
" Pool product ' { $cartable -> name } ' has only { $available } items available for the requested period. Requested: { $quantity } "
);
}
} else {
$available = $cartable -> getPoolMaxQuantity ();
if ( $available !== PHP_INT_MAX && $quantity > $available ) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException (
" Pool product ' { $cartable -> name } ' has only { $available } items available. Requested: { $quantity } "
);
}
}
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 ) {
throw new \Exception ( " 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' ) } " );
}
// Check booking product availability if dates are provided
2025-12-18 15:54:33 +00:00
if ( $cartable -> isBooking () && ! $cartable -> isPool () && ! $cartable -> isAvailableForBooking ( $from , $until , $quantity )) {
2025-12-15 11:28:15 +00:00
throw new \Blax\Shop\Exceptions\NotEnoughStockException (
" 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
if ( $cartable -> isPool ()) {
$maxQuantity = $cartable -> getPoolMaxQuantity ( $from , $until );
2025-12-17 11:26:26 +00:00
// Only validate if pool has limited availability AND quantity exceeds it
2025-12-15 13:10:59 +00:00
if ( $maxQuantity !== PHP_INT_MAX && $quantity > $maxQuantity ) {
2025-12-15 11:28:15 +00:00
throw new \Blax\Shop\Exceptions\NotEnoughStockException (
" Pool product ' { $cartable -> name } ' has only { $maxQuantity } items available for the requested period ( { $from -> format ( 'Y-m-d' ) } to { $until -> format ( 'Y-m-d' ) } ). Requested: { $quantity } "
);
}
}
} elseif ( $from || $until ) {
// If only one date is provided, it's an error
throw new \Exception ( " Both 'from' and 'until' dates must be provided together, or both omitted. " );
2025-12-15 13:10:59 +00:00
} else {
// Even without dates, check pool quantity limits
if ( $cartable -> isPool ()) {
$maxQuantity = $cartable -> getPoolMaxQuantity ();
// Skip validation if pool has unlimited availability
if ( $maxQuantity !== PHP_INT_MAX ) {
// Get current quantity in cart for this pool product
$currentQuantityInCart = $this -> items ()
-> where ( 'purchasable_id' , $cartable -> getKey ())
-> where ( 'purchasable_type' , get_class ( $cartable ))
-> sum ( 'quantity' );
$totalQuantity = $currentQuantityInCart + $quantity ;
if ( $totalQuantity > $maxQuantity ) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException (
" Pool product ' { $cartable -> name } ' has only { $maxQuantity } items available. Already in cart: { $currentQuantityInCart } , Requested: { $quantity } "
);
}
}
}
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-16 12:58:03 +00:00
if ( $cartable instanceof Product && $cartable -> isPool ()) {
$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-19 09:57:26 +00:00
-> first ( function ( $item ) use ( $parameters , $from , $until , $cartable , $poolPriceId ) {
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 09:57:26 +00:00
// For pool products, check if price_id matches to allow proper merging
// Pool items with the same price_id (from the same single item) can merge
// but items from different single items (different price_id) should NOT merge
// Also check that the actual price matches (important for AVERAGE strategy where price can change)
2025-12-15 13:10:59 +00:00
$priceMatch = true ;
if ( $cartable instanceof Product && $cartable -> isPool ()) {
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 ;
// Only merge if price_id matches AND the price amount matches
$priceMatch = $poolPriceId && $item -> price_id === $poolPriceId &&
$expectedPrice !== null && $item -> unit_amount === ( int ) round ( $expectedPrice );
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
if ( $cartable instanceof Product && $cartable -> isPool ()) {
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-16 12:58:03 +00:00
if ( $cartable instanceof Product && $cartable -> isPool ()) {
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-17 15:43:22 +00:00
throw new \Exception ( " Product ' { $cartable -> name } ' has no valid price. " );
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
$pricePerUnit = ( int ) round ( $pricePerDay * $days );
$regularPricePerUnit = ( int ) round ( $regularPricePerDay * $days );
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 ) {
throw new \Exception ( " Cart item price calculation resulted in null for ' { $cartable -> name } ' (pricePerDay: { $pricePerDay } , days: { $days } ) " );
}
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
if ( $cartable -> isPool () && $poolPriceId ) {
$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
*
* @ 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
{
$items = $this -> items ()
-> with ( 'purchasable' )
-> get ();
if ( $items -> isEmpty ()) {
2025-12-18 14:33:47 +00:00
if ( $throws ) {
throw new \Exception ( " Cart is empty " );
} 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 ) {
throw new \Exception ( " Cart item ' { $productName } ' is missing required information: { $missingFields } " );
} else {
return false ;
}
2025-12-15 13:10:59 +00:00
}
}
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 {
$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-18 14:33:47 +00:00
if ( ! @ $this -> validateForCheckout ( false )) {
2025-12-18 11:21:29 +00:00
return null ;
}
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
}