2025-11-21 10:49:41 +00:00
< ? php
namespace Blax\Shop\Models ;
2025-12-17 11:47:18 +00:00
use Blax\Shop\Casts\HtmlDateTimeCast ;
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-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-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 ;
class Cart extends Model
{
2025-11-29 11:05:02 +00:00
use HasUuids , HasExpiration , HasFactory ;
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-17 11:26:26 +00:00
'from_date' ,
'until_date' ,
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-17 11:47:18 +00:00
'from_date' => HtmlDateTimeCast :: class ,
'until_date' => HtmlDateTimeCast :: class ,
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' );
}
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 );
}
/**
* 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 .
*
* @ param \DateTimeInterface $from Start date
* @ param \DateTimeInterface $until End date
* @ param bool $validateAvailability Whether to validate product availability for the timespan
* @ return $this
* @ throws InvalidDateRangeException
* @ throws NotEnoughAvailableInTimespanException
*/
public function setDates ( \DateTimeInterface $from , \DateTimeInterface $until , bool $validateAvailability = true ) : self
{
if ( $from >= $until ) {
throw new InvalidDateRangeException ();
}
if ( $validateAvailability ) {
$this -> validateDateAvailability ( $from , $until );
}
$this -> update ([
'from_date' => $from ,
'until_date' => $until ,
]);
return $this -> fresh ();
}
/**
* Set the 'from' date for the cart .
*
* @ param \DateTimeInterface $from Start date
* @ param bool $validateAvailability Whether to validate product availability for the timespan
* @ return $this
* @ throws InvalidDateRangeException
* @ throws NotEnoughAvailableInTimespanException
*/
public function setFromDate ( \DateTimeInterface $from , bool $validateAvailability = true ) : self
{
if ( $this -> until_date && $from >= $this -> until_date ) {
throw new InvalidDateRangeException ();
}
if ( $validateAvailability && $this -> until_date ) {
$this -> validateDateAvailability ( $from , $this -> until_date );
}
$this -> update ([ 'from_date' => $from ]);
return $this -> fresh ();
}
/**
* Set the 'until' date for the cart .
*
* @ param \DateTimeInterface $until End date
* @ param bool $validateAvailability Whether to validate product availability for the timespan
* @ return $this
* @ throws InvalidDateRangeException
* @ throws NotEnoughAvailableInTimespanException
*/
public function setUntilDate ( \DateTimeInterface $until , bool $validateAvailability = true ) : self
{
if ( $this -> from_date && $this -> from_date >= $until ) {
throw new InvalidDateRangeException ();
}
if ( $validateAvailability && $this -> from_date ) {
$this -> validateDateAvailability ( $this -> from_date , $until );
}
$this -> update ([ 'until_date' => $until ]);
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
* @ return $this
* @ throws NotEnoughAvailableInTimespanException
*/
public function applyDatesToItems ( bool $validateAvailability = true ) : self
{
if ( ! $this -> from_date || ! $this -> until_date ) {
return $this ;
}
foreach ( $this -> items as $item ) {
// Only apply to items without dates that are booking products
if ( $item -> is_booking && ( ! $item -> from || ! $item -> until )) {
if ( $validateAvailability ) {
$product = $item -> purchasable ;
if ( $product && ! $product -> isAvailableForBooking ( $this -> from_date , $this -> until_date , $item -> quantity )) {
throw new NotEnoughAvailableInTimespanException (
productName : $product -> name ? ? 'Product' ,
requested : $item -> quantity ,
available : 0 , // Could calculate actual available amount
from : $this -> from_date ,
until : $this -> until_date
);
}
}
$item -> updateDates ( $this -> from_date , $this -> until_date );
}
}
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-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-11-22 17:09:45 +00:00
protected static function booted ()
{
static :: deleting ( function ( $cart ) {
$cart -> items () -> delete ();
});
}
2025-11-23 14:07:12 +00:00
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
if ( $cartable -> isBooking () && ! $cartable -> isAvailableForBooking ( $from , $until , $quantity )) {
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 ;
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-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-16 12:58:03 +00:00
-> first ( function ( $item ) use ( $parameters , $from , $until , $cartable , $currentQuantityInCart ) {
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-16 12:58:03 +00:00
// For pool products, check pricing strategy to determine merge behavior
2025-12-15 13:10:59 +00:00
$priceMatch = true ;
if ( $cartable instanceof Product && $cartable -> isPool ()) {
2025-12-17 09:41:52 +00:00
// For pools, use smart pricing that considers which tiers are used
$currentPrice = $cartable -> getNextAvailablePoolPriceConsideringCart ( $this , null , $from , $until );
2025-12-16 12:58:03 +00:00
if ( ! $currentPrice ) {
2025-12-17 09:41:52 +00:00
// Fallback to getCurrentPrice if method returns null
2025-12-16 12:58:03 +00:00
$currentPrice = $cartable -> getCurrentPrice ();
}
2025-12-15 13:10:59 +00:00
if ( $from && $until ) {
$days = max ( 1 , $from -> diff ( $until ) -> days );
$currentPrice *= $days ;
}
2025-12-16 12:58:03 +00:00
// Compare prices - merge if prices match
2025-12-15 13:10:59 +00:00
$priceMatch = abs (( float ) $item -> price - $currentPrice ) < 0.01 ;
}
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
$pricePerDay = $cartable -> getNextAvailablePoolPriceConsideringCart ( $this , null , $from , $until );
$regularPricePerDay = $cartable -> getNextAvailablePoolPriceConsideringCart ( $this , false , $from , $until ) ? ? $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 ()) {
$pricePerDay = $cartable -> defaultPrice () -> first () ? -> getCurrentPrice ( $cartable -> isOnSale ());
$regularPricePerDay = $cartable -> defaultPrice () -> first () ? -> getCurrentPrice ( false ) ? ? $pricePerDay ;
}
} 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
$debugInfo = '' ;
if ( $cartable instanceof Product && $cartable -> isPool ()) {
$debugInfo = " (Pool product, currentQuantityInCart: { $currentQuantityInCart } , hasPrice: " . ( $cartable -> hasPrice () ? 'yes' : 'no' ) . " ) " ;
}
throw new \Exception ( " Product ' { $cartable -> name } ' has no valid price. { $debugInfo } " );
2025-12-15 11:28:15 +00:00
}
// Calculate days if booking dates provided
$days = 1 ;
if ( $from && $until ) {
$days = max ( 1 , $from -> diff ( $until ) -> days );
}
// Calculate price per unit for the entire period
$pricePerUnit = $pricePerDay * $days ;
$regularPricePerUnit = $regularPricePerDay * $days ;
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-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 ) {
// Get the default price for the product
$defaultPrice = $cartable -> defaultPrice () -> first ();
$priceId = $defaultPrice ? -> id ;
} 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 ,
'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-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
*/
public function validateForCheckout () : void
2025-11-29 11:05:02 +00:00
{
$items = $this -> items ()
-> with ( 'purchasable' )
-> get ();
if ( $items -> isEmpty ()) {
throw new \Exception ( " Cart is empty " );
}
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 ));
throw new \Exception ( " Cart item ' { $productName } ' is missing required information: { $missingFields } " );
}
}
}
public function checkout () : static
{
// Validate cart before proceeding
$this -> validateForCheckout ();
$items = $this -> items ()
-> with ( 'purchasable' )
-> get ();
2025-11-29 11:05:02 +00:00
// Create ProductPurchase for each cart item
foreach ( $items as $item ) {
$product = $item -> purchasable ;
$quantity = $item -> quantity ;
2025-12-09 08:42:59 +00:00
2025-12-15 10:32:31 +00:00
// 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-03 12:59:01 +00:00
}
2025-12-15 10:32:31 +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). " );
}
// 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 } "
);
// 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-03 12:59:01 +00:00
}
}
2025-11-29 11:05:02 +00:00
2025-12-15 10:32:31 +00:00
// Validate booking products have required dates
if ( $product instanceof Product && $product -> isBooking () && ( ! $from || ! $until )) {
throw new \Exception ( " Booking product ' { $product -> name } ' requires a timespan (from/until dates). " );
}
2025-11-29 11:05:02 +00:00
$purchase = $this -> customer -> purchase (
$product -> prices () -> first (),
2025-12-03 12:59:01 +00:00
$quantity ,
null ,
$from ,
$until
2025-11-29 11:05:02 +00:00
);
$purchase -> update ([
'cart_id' => $item -> cart_id ,
]);
// Remove item from cart
$item -> update ([
'purchase_id' => $purchase -> id ,
]);
}
$this -> update ([
'converted_at' => now (),
]);
return $this ;
}
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 )
* - Syncs products / prices to Stripe ( creates them if they don ' t exist )
* - Creates line items with descriptions including booking dates
* - Returns the Stripe checkout session
*
* @ param array $options Optional session parameters ( success_url , cancel_url , etc . )
* @ return \Stripe\Checkout\Session
* @ throws \Exception
*/
public function checkoutSession ( array $options = []) : \Stripe\Checkout\Session
{
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-17 11:26:26 +00:00
// Get all stripe price IDs and validate they exist
$stripePriceIds = $this -> stripePriceIds ();
2025-12-15 13:10:59 +00:00
2025-12-17 11:26:26 +00:00
// Check if any stripe_price_id is null
$nullPriceIndexes = [];
foreach ( $stripePriceIds as $index => $priceId ) {
if ( $priceId === null ) {
$nullPriceIndexes [] = $index ;
2025-12-15 13:10:59 +00:00
}
2025-12-17 11:26:26 +00:00
}
2025-12-15 13:10:59 +00:00
2025-12-17 11:26:26 +00:00
if ( ! empty ( $nullPriceIndexes )) {
// Get item names for better error message
$itemNames = [];
foreach ( $nullPriceIndexes as $index ) {
$item = $this -> items [ $index ];
$itemNames [] = $item -> purchasable -> name ? ? " Item { $index } " ;
2025-12-15 13:10:59 +00:00
}
2025-12-17 11:26:26 +00:00
throw new \Exception (
" Cannot create checkout session: The following items have no Stripe price ID: " .
implode ( ', ' , $itemNames )
);
}
$syncService = new \Blax\Shop\Services\StripeSyncService ();
$lineItems = [];
2025-12-15 13:10:59 +00:00
2025-12-17 11:26:26 +00:00
foreach ( $this -> items as $index => $item ) {
// Use the pre-fetched stripe price ID
$stripePriceId = $stripePriceIds [ $index ];
2025-12-15 13:10:59 +00:00
// Build line item with description including booking dates if applicable
$lineItem = [
'price' => $stripePriceId ,
'quantity' => $item -> quantity ,
];
// Add description with booking dates if available
$description = null ;
if ( $item -> from && $item -> until ) {
$days = max ( 1 , $item -> from -> diffInDays ( $item -> until ));
$fromFormatted = $item -> from -> format ( 'M j, Y' );
$untilFormatted = $item -> until -> format ( 'M j, Y' );
$description = " Period: { $fromFormatted } to { $untilFormatted } ( { $days } day " . ( $days > 1 ? 's' : '' ) . " ) " ;
}
if ( $description ) {
$lineItem [ 'description' ] = $description ;
}
$lineItems [] = $lineItem ;
}
// Prepare session parameters
$sessionParams = [
'payment_method_types' => [ 'card' ],
'line_items' => $lineItems ,
'mode' => 'payment' ,
'success_url' => $options [ 'success_url' ] ? ? route ( 'shop.stripe.success' ) . '?session_id={CHECKOUT_SESSION_ID}&cart_id=' . $this -> id ,
'cancel_url' => $options [ 'cancel_url' ] ? ? route ( 'shop.stripe.cancel' ) . '?cart_id=' . $this -> id ,
'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-11-21 10:49:41 +00:00
}