CartStatus::class, 'expires_at' => 'datetime', 'converted_at' => 'datetime', 'last_activity_at' => 'datetime', 'meta' => 'object', 'from' => 'datetime', 'until' => 'datetime', ]; protected $appends = [ 'is_full_booking', 'is_ready_to_checkout', ]; public function __construct(array $attributes = []) { parent::__construct($attributes); $this->table = config('shop.tables.carts', 'carts'); } protected static function booted() { static::deleting(function ($cart) { $cart->items()->delete(); }); } 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'); } /** * Get the order created from this cart (if converted). */ public function order() { return $this->hasOne(config('shop.models.order', \Blax\Shop\Models\Order::class), 'cart_id'); } public function getTotal(): float { return $this->items()->sum('subtotal'); } public function getTotalItems(): int { return $this->items->sum('quantity'); } /** * 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); } /** * 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); } /** * 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 */ 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); } /** * 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(); } /** * Set the default date range for the cart. * Items without specific dates will use these as fallback. * * @param \DateTimeInterface|string $from Start date (DateTimeInterface or parsable string) * @param \DateTimeInterface|string $until End date (DateTimeInterface or parsable string) * @param bool $validateAvailability Whether to validate product availability for the timespan * @return $this * @throws InvalidDateRangeException * @throws NotEnoughAvailableInTimespanException */ public function setDates( \DateTimeInterface|string|int|float|null $from, \DateTimeInterface|string|int|float|null $until, bool $validateAvailability = true, bool $overwrite_item_dates = true ): self { // Parse string dates using Carbon if ($from !== null && (is_string($from) || is_numeric($from))) { $from = Carbon::parse($from); } if ($until !== null && (is_string($until) || is_numeric($until))) { $until = Carbon::parse($until); } // Always update cart dates with provided values $updateData = []; if ($from !== null) { $updateData['from'] = $from; } if ($until !== null) { $updateData['until'] = $until; } if (!empty($updateData)) { $this->update($updateData); $this->refresh(); } // 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; } 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 ); } return $this->fresh(); } /** * Set the 'from' date for the cart. * * @param \DateTimeInterface|string $from Start date (DateTimeInterface or parsable string) * @param bool $validateAvailability Whether to validate product availability for the timespan * @return $this * @throws NotEnoughAvailableInTimespanException */ public function setFromDate( \DateTimeInterface|string|int|float $from, bool $validateAvailability = true ): self { // Parse string dates using Carbon if (is_string($from) || is_numeric($from)) { $from = Carbon::parse($from); } // 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; } if ($validateAvailability) { $this->validateDateAvailability($calcFrom, $calcUntil); } // Update cart items with new dates and recalculate prices $this->applyDatesToItems( $validateAvailability, true, $calcFrom, $calcUntil ); } return $this->fresh(); } /** * Set the 'until' date for the cart. * * @param \DateTimeInterface|string $until End date (DateTimeInterface or parsable string) * @param bool $validateAvailability Whether to validate product availability for the timespan * @return $this * @throws NotEnoughAvailableInTimespanException */ public function setUntilDate(\DateTimeInterface|string|int|float $until, bool $validateAvailability = true): self { // Parse string dates using Carbon if (is_string($until) || is_numeric($until)) { $until = Carbon::parse($until); } // 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; } if ($validateAvailability) { $this->validateDateAvailability($calcFrom, $calcUntil); } // Update cart items with new dates and recalculate prices $this->applyDatesToItems( $validateAvailability, true, $calcFrom, $calcUntil ); } 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 * @param bool $overwrite If true, overwrites existing item dates. If false, only sets null fields. * @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) * @return $this * @throws NotEnoughAvailableInTimespanException */ 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) { return $this; } // 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 = []; foreach ($this->items as $item) { // 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; } $itemFrom = $shouldApplyFrom ? $fromDate : $item->from; $itemUntil = $shouldApplyUntil ? $untilDate : $item->until; if ($validateAvailability) { $product = $item->purchasable; // For pool products, check if allocated by reallocatePoolItems if ($product instanceof Product && $product->isPool()) { $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; } $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; $poolValidation[$poolKey]['allocated'] += $item->quantity; } elseif ($product && !$product->isAvailableForBooking($itemFrom, $itemUntil, $item->quantity)) { // 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; } } $item->updateDates($itemFrom, $itemUntil); } } // 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(). return $this->fresh(); } /** * 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; } // Build list of available singles with their prices for new dates $singlesWithPrices = []; foreach ($singleItems as $single) { // Get available stock at the booking start date // This already accounts for claims via the DECREASE entries they create $effectiveAvailable = $single->getAvailableStock($from); if ($effectiveAvailable > 0) { $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) { $singlesWithPrices[] = [ 'single' => $single, 'price' => $price, 'price_id' => $priceModel?->id, 'available' => $effectiveAvailable, ]; } } } if (empty($singlesWithPrices)) { // 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, ]); } continue; } // Sort by pricing strategy usort($singlesWithPrices, function ($a, $b) use ($strategy) { 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 // 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; foreach ($items as $cartItem) { // Only reallocate if we should overwrite or item has no dates yet if (!$overwrite && $cartItem->from && $cartItem->until) { continue; } $neededQty = $cartItem->quantity; $allocated = false; // 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); // Update price_id if changed if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) { $cartItem->update(['price_id' => $singleInfo['price_id']]); } // Track usage $singleUsage[$single->id] = $usedFromSingle + $neededQty; $allocated = true; break; } } if (!$allocated) { // 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) } } } } } } /** * 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 */ /** * 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 */ protected function validateDateAvailability(\DateTimeInterface $from, \DateTimeInterface $until, bool $useProvidedDates = false): void { foreach ($this->items as $item) { if (!$item->is_booking) { continue; } $product = $item->purchasable; if (!$product) { continue; } // Skip pool products - they are handled by reallocatePoolItems() if ($product->type === ProductType::POOL) { continue; } // 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); if (!$product->isAvailableForBooking($checkFrom, $checkUntil, $item->quantity)) { // Mark item as unavailable instead of throwing exception // This allows users to freely adjust dates $item->update([ 'price' => null, 'subtotal' => null, 'unit_amount' => null, ]); } } } /** * 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)); } 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'); } 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); } public static function scopeUnpaid($query) { return $query->whereDoesntHave('purchases', function ($q) { $q->whereColumn('total_amount', '!=', 'amount_paid'); }); } /** * 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]); } /** * 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 $parameters Additional parameters for the cart item * @param \DateTimeInterface|null $from Optional start date for bookings * @param \DateTimeInterface|null $until Optional end date for bookings * @return CartItem * @throws \Exception If the item doesn't implement Cartable interface */ public function addToCart( Model $cartable, int $quantity = 1, array $parameters = [], null|\DateTimeInterface $from = null, null|\DateTimeInterface $until = null ): CartItem { // $cartable must implement Cartable if (! $cartable instanceof Cartable) { throw new CartableInterfaceException(); } 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(); } if ($is_booking) { // 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']; } // Fallback to cart dates if no dates provided if (!$from && $this->from) { $from = $this->from; } if (!$until && $this->until) { $until = $this->until; } } // For pool products with quantity > 1, add them one at a time to get progressive pricing if ($is_pool && $quantity > 1) { // Validate availability if dates are provided if ($from && $until) { $available = $cartable->getPoolMaxQuantity($from, $until); // 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) { throw new NotEnoughStockException( "Pool product '{$cartable->name}' has only {$availableForThisRequest} items available for the requested period. Requested: {$quantity}" ); } } 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'); $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}" ); } } // 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; } // Validate Product-specific requirements if ($cartable instanceof Product) { // Validate pricing before adding to cart $cartable->validatePricing(throwExceptions: true); // Validate dates if both are provided if ($from && $until) { // Validate from is before until if ($from >= $until) { 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')}"); } // For booking products (non-pool), validate against total stock capacity // Date-based validation will happen at checkout if ( $is_booking && !$is_pool && $cartable->manage_stock ) { $totalStock = $cartable->getAvailableStock(); $itemsInCart = $this->items() ->where('purchasable_id', $cartable->getKey()) ->where('purchasable_type', get_class($cartable)) ->sum('quantity'); $availableForThisRequest = max(0, $totalStock - $itemsInCart); if ($quantity > $availableForThisRequest) { throw new NotEnoughStockException( "Product '{$cartable->name}' has only {$availableForThisRequest} items available. Requested: {$quantity}" ); } } // Check pool product availability against total capacity (NOT date-restricted) // Date-based validation will happen at checkout, allowing users to add items // and then adjust dates to find available periods if ($is_pool) { $totalCapacity = $cartable->getPoolTotalCapacity(); // Total capacity ignoring claims // Subtract items already in cart for this pool $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); // Only prevent adding if it exceeds total pool capacity if ($availableForThisRequest !== PHP_INT_MAX && $quantity > $availableForThisRequest) { throw new NotEnoughStockException( "Pool product '{$cartable->name}' has only {$availableForThisRequest} items available. Requested: {$quantity}" ); } } } elseif ($from || $until) { // If only one date is provided, it's an error throw new CartDatesRequiredException(); } else { // When adding pool items without dates, validate against total pool capacity // This allows adding items even if currently claimed - date-based validation happens later if ($is_pool) { $totalCapacity = $cartable->getPoolTotalCapacity(); // Total capacity ignoring claims // 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 } } // 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; $poolSingleItem = null; $poolPriceId = null; if ($is_pool) { $this->unsetRelation('items'); // Clear cached relationship $currentQuantityInCart = $this->items() ->where('purchasable_id', $cartable->getKey()) ->where('purchasable_type', get_class($cartable)) ->sum('quantity'); // 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']; } } // Check if item already exists in cart with same parameters, dates, AND price $existingItem = $this->items() ->where('purchasable_id', $cartable->getKey()) ->where('purchasable_type', get_class($cartable)) ->get() ->first(function ($item) use ($parameters, $from, $until, $cartable, $poolPriceId, $is_pool) { $existingParams = is_array($item->parameters) ? $item->parameters : (array) $item->parameters; // Sort both arrays to ensure consistent comparison ksort($existingParams); ksort($parameters); // 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')) ); } // 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) $priceMatch = true; if ($is_pool) { // Calculate expected price for this item $poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until); $expectedPrice = $poolItemData['price'] ?? null; $expectedSingleItemId = $poolItemData['item']?->id ?? null; // 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) $priceMatch = $poolPriceId && $item->price_id === $poolPriceId && $expectedPrice !== null && $item->unit_amount === (int) round($expectedPrice) && $expectedSingleItemId !== null && $existingAllocatedItemId === $expectedSingleItemId; } return $paramsMatch && $datesMatch && $priceMatch; }); // Calculate price per day (base price) // For pool products, get price based on how many items are already in cart if ($is_pool) { // Use smarter pricing that considers which price tiers are used $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; // If no price found from pool items, try the pool's direct price as fallback if ($pricePerDay === null && $cartable->hasPrice()) { $priceModel = $cartable->defaultPrice()->first(); $pricePerDay = $priceModel?->getCurrentPrice($cartable->isOnSale()); $regularPricePerDay = $priceModel?->getCurrentPrice(false) ?? $pricePerDay; $poolPriceId = $priceModel?->id; // Still try to find a single item for allocation even with pool's direct price // This ensures allocated_single_item_name is always set for pool items if (!$poolSingleItem) { $singleItems = $cartable->singleProducts; foreach ($singleItems as $single) { // Find first single with available capacity $available = $single->manage_stock ? $single->getAvailableStock() : PHP_INT_MAX; if ($available > 0) { // Check how many are already in cart for this single $inCart = $this->items() ->where('purchasable_id', $cartable->getKey()) ->where('purchasable_type', get_class($cartable)) ->get() ->filter(function ($item) use ($single) { $meta = $item->getMeta(); return isset($meta->allocated_single_item_id) && $meta->allocated_single_item_id == $single->id; }) ->sum('quantity'); if ($available === PHP_INT_MAX || $inCart < $available) { $poolSingleItem = $single; break; } } } } } } else { $pricePerDay = $cartable->getCurrentPrice(); $regularPricePerDay = $cartable->getCurrentPrice(false) ?? $pricePerDay; } // Ensure prices are not null if ($pricePerDay === null) { if ($is_pool) { // For pool products, throw specific error when neither pool nor single items have prices throw \Blax\Shop\Exceptions\HasNoPriceException::poolProductNoPriceAndNoSingleItemPrices($cartable->name); } throw new ProductHasNoPriceException($cartable->name); } // Calculate days if booking dates provided $days = 1; if ($from && $until) { $days = $this->calculateBookingDays($from, $until); } // Calculate price per unit for the entire period and round to nearest cent for consistency 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); } // Defensive check - ensure pricePerUnit is not null if ($pricePerUnit === null) { throw new PriceCalculationException($cartable->name, $pricePerDay, $days); } // Store the base unit_amount (price for 1 quantity, 1 day) in cents $unitAmount = (int) round($pricePerDay); // Calculate total price $totalPrice = $pricePerUnit * $quantity; if ($existingItem) { // Update quantity and subtotal $newQuantity = $existingItem->quantity + $quantity; $existingItem->update([ 'quantity' => $newQuantity, 'subtotal' => $pricePerUnit * $newQuantity, ]); return $existingItem->fresh(); } // Determine price_id for the cart item $priceId = null; if ($cartable instanceof Product) { // For pool products, use the single item's price_id if ($is_pool && $poolPriceId) { $priceId = $poolPriceId; } else { // 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; } // Create new cart item $cartItem = $this->items()->create([ 'purchasable_id' => $cartable->getKey(), 'purchasable_type' => get_class($cartable), 'price_id' => $priceId, 'quantity' => $quantity, 'price' => $pricePerUnit, // Price per unit for the period 'regular_price' => $regularPricePerUnit, 'unit_amount' => $unitAmount, // Base price for 1 quantity, 1 day (in cents) 'subtotal' => $totalPrice, // Total for all units 'parameters' => $parameters, 'from' => ($is_booking) ? $from : null, 'until' => ($is_booking) ? $until : null, ]); // 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); } return $cartItem; } public function removeFromCart( Model $cartable, int $quantity = 1, array $parameters = [] ): CartItem|true { // 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() ->where('purchasable_id', $cartable->getKey()) ->where('purchasable_type', get_class($cartable)) ->get() ->filter(function ($item) use ($parameters) { $existingParams = is_array($item->parameters) ? $item->parameters : (array) $item->parameters; ksort($existingParams); ksort($parameters); return $existingParams === $parameters; }); 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(); if ($item) { 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 ?? true; } /** * Get calendar availability for all items in the cart. * * This method aggregates availability across all cart items and returns * the minimum availability for each date. This is useful for booking systems * where you need to know when ALL items in a cart can be booked together. * * For each date, it calculates the minimum number of complete cart "sets" * that could be fulfilled. A set is fulfilled when all items have at least * one unit available. * * Returns associative array with keys: * - 'max_available' => Shows the peak available "sets" in the date range * - 'min_available' => Shows the lowest available "sets" in the date range * - 'dates' => An array of dates with their respective min/max availability * - 'items' => Individual item availability data (for debugging) * * @param \DateTimeInterface|null $from Start date of the range (optional, defaults to today) * @param \DateTimeInterface|null $until End date of the range (optional, defaults to 30 days) * @return array Associative array with 'max_available', 'min_available', 'dates', and 'items' */ public function calendarAvailability( ?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null ): array { $fromDate = Carbon::parse($from ?? now())->startOfDay(); $untilDate = Carbon::parse($until ?? $fromDate->copy()->addDays(30))->endOfDay(); // Load items with their purchasable products if (!$this->relationLoaded('items')) { $this->load('items.purchasable'); } $items = $this->items; if ($items->isEmpty()) { return [ 'max_available' => PHP_INT_MAX, 'min_available' => PHP_INT_MAX, 'dates' => [], 'items' => [], ]; } // Collect availability data for each unique product in the cart $productAvailabilities = []; $itemDetails = []; // Group items by product to handle multiple quantities of the same product $productQuantities = []; foreach ($items as $item) { $product = $item->purchasable; if (!$product) { continue; } $productKey = get_class($product) . '|' . $product->id; if (!isset($productQuantities[$productKey])) { $productQuantities[$productKey] = [ 'product' => $product, 'quantity' => 0, ]; } $productQuantities[$productKey]['quantity'] += $item->quantity; } // Get calendar availability for each unique product foreach ($productQuantities as $productKey => $data) { $product = $data['product']; $requiredQuantity = $data['quantity']; // Check if product has the calendarAvailability method (uses HasStocks trait) if (method_exists($product, 'calendarAvailability')) { $availability = $product->calendarAvailability($from, $until); $productAvailabilities[$productKey] = [ 'availability' => $availability, 'required_quantity' => $requiredQuantity, ]; $itemDetails[$productKey] = [ 'product_id' => $product->id, 'product_name' => $product->name ?? 'Unknown', 'required_quantity' => $requiredQuantity, 'availability' => $availability, ]; } else { // Product doesn't have stock management - treat as unlimited $productAvailabilities[$productKey] = [ 'availability' => [ 'max_available' => PHP_INT_MAX, 'min_available' => PHP_INT_MAX, 'dates' => [], ], 'required_quantity' => $requiredQuantity, ]; $itemDetails[$productKey] = [ 'product_id' => $product->id, 'product_name' => $product->name ?? 'Unknown', 'required_quantity' => $requiredQuantity, 'availability' => [ 'max_available' => PHP_INT_MAX, 'min_available' => PHP_INT_MAX, 'dates' => [], ], ]; } } // If no products have availability data, return unlimited if (empty($productAvailabilities)) { return [ 'max_available' => PHP_INT_MAX, 'min_available' => PHP_INT_MAX, 'dates' => [], 'items' => $itemDetails, ]; } // Build the combined calendar $dates = []; $globalMin = PHP_INT_MAX; $globalMax = PHP_INT_MIN; $currentDate = $fromDate->copy(); while ($currentDate->lte($untilDate)) { $dateKey = $currentDate->toDateString(); $dayMin = PHP_INT_MAX; $dayMax = PHP_INT_MAX; foreach ($productAvailabilities as $productKey => $data) { $availability = $data['availability']; $requiredQuantity = $data['required_quantity']; // Get the availability for this date if (isset($availability['dates'][$dateKey])) { $productDayData = $availability['dates'][$dateKey]; $productDayMin = $productDayData['min'] ?? 0; $productDayMax = $productDayData['max'] ?? 0; } else { // No specific date data - use overall availability $productDayMin = $availability['min_available'] ?? 0; $productDayMax = $availability['max_available'] ?? 0; } // Calculate how many "sets" of the required quantity are available if ($productDayMin === PHP_INT_MAX) { $setsMin = PHP_INT_MAX; } else { $setsMin = $requiredQuantity > 0 ? intdiv($productDayMin, $requiredQuantity) : PHP_INT_MAX; } if ($productDayMax === PHP_INT_MAX) { $setsMax = PHP_INT_MAX; } else { $setsMax = $requiredQuantity > 0 ? intdiv($productDayMax, $requiredQuantity) : PHP_INT_MAX; } // The cart availability is limited by the product with the least availability $dayMin = min($dayMin, $setsMin); $dayMax = min($dayMax, $setsMax); } // Handle PHP_INT_MAX edge case if ($dayMin === PHP_INT_MAX) { $dayMin = PHP_INT_MAX; } if ($dayMax === PHP_INT_MAX) { $dayMax = PHP_INT_MAX; } $dates[$dateKey] = [ 'min' => $dayMin, 'max' => $dayMax, ]; if ($dayMin !== PHP_INT_MAX) { $globalMin = min($globalMin, $dayMin); } if ($dayMax !== PHP_INT_MAX && $dayMax !== PHP_INT_MIN) { $globalMax = max($globalMax, $dayMax); } elseif ($dayMax === PHP_INT_MAX && $globalMax === PHP_INT_MIN) { // All products have unlimited availability $globalMax = PHP_INT_MAX; } $currentDate->addDay(); } return [ 'max_available' => $globalMax === PHP_INT_MIN ? 0 : $globalMax, 'min_available' => $globalMin === PHP_INT_MAX ? PHP_INT_MAX : $globalMin, 'dates' => $dates, 'items' => $itemDetails, ]; } /** * Validate cart for checkout without converting it * * 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) * * @throws \Exception */ public function validateForCheckout(bool $throws = true): bool { // Check if cart is already converted if ($this->isConverted()) { if ($throws) { throw new CartAlreadyConvertedException(); } else { return false; } } $items = $this->items() ->with('purchasable') ->get(); if ($items->isEmpty()) { if ($throws) { throw new CartEmptyException(); } else { return false; } } // 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)); if ($throws) { throw new CartItemMissingInformationException($productName, $missingFields); } else { return false; } } } // Validate stock availability for all items foreach ($items as $item) { $product = $item->purchasable; if (!($product instanceof Product)) { continue; } // Use effective dates (item-specific or cart fallback) $from = $item->getEffectiveFromDate(); $until = $item->getEffectiveUntilDate(); // 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) { throw new NotEnoughStockException( "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) { throw new NotEnoughStockException( "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) { throw new NotEnoughStockException( "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) { throw new NotEnoughStockException( "Product '{$product->name}' has only {$available} items in stock. Requested: {$item->quantity}" ); } else { return false; } } } } return true; } /** * Convert this cart into purchases (atomic checkout). * * This method performs an in-database checkout and is intended to be safe against * concurrent requests. It does not take payment; it turns each cart item into a * purchase and (where applicable) claims stock for the booked timespan. * * Step-by-step: * 1) Start a database transaction so the entire checkout is atomic. * 2) Lock the cart row (`lockForUpdate`) to prevent concurrent checkouts of the same cart. * 3) Validate the cart via `validateForCheckout()`: * - cart is not already converted * - cart is not empty * - all items have required information (e.g. booking dates) * - stock is available for each item (including booking/pool checks when dates exist) * 4) Load cart items with their `purchasable` models. * 5) For each cart item: * a) Resolve the purchasable product and lock it (when supported) to reduce stock race conditions. * b) Determine quantity. * c) Resolve booking dates: * - Prefer the cart-item `from`/`until` columns. * - Fallback to legacy `$item->parameters['from'|'until']` for BOOKING/POOL items. * - Parse string dates into Carbon instances. * d) If the product is a pool: * - If the pool contains booking single items, a timespan is required. * - When a timespan exists and booking singles are used, claim stock: * - Use a pre-allocated single item from item meta (`allocated_single_item_id`) when present. * - Otherwise call the pool stock claiming logic (`claimPoolStock`). * - Persist claimed single-item IDs into cart item meta (`claimed_single_items`). * e) If the product is a non-pool booking product, require a timespan. * f) Create a purchase via `$this->customer->purchase(...)` using the product's first price, * passing quantity and booking dates. * g) Link the purchase back to the cart (`cart_id`) and link the cart item to the purchase (`purchase_id`). * 6) Mark the cart as converted by setting `converted_at`. * 7) Commit the transaction and return the updated cart instance. * * Side effects: * - Creates one purchase record per cart item. * - Claims stock for booking/pool items when dates are provided and required. * - Updates cart items with `purchase_id` and the cart with `converted_at`. * * @return static The converted cart (fresh state within the transaction scope). * * @throws \Blax\Shop\Exceptions\CartAlreadyConvertedException * @throws \Blax\Shop\Exceptions\CartEmptyException * @throws \Blax\Shop\Exceptions\CartItemMissingInformationException * @throws \Blax\Shop\Exceptions\NotEnoughStockException * @throws \Throwable For any other unexpected failures during checkout/stock claiming. */ public function checkout(): static { return DB::transaction(function () { // Lock the cart to prevent concurrent checkouts $this->lockForUpdate(); // Validate cart before proceeding $this->validateForCheckout(); $items = $this->items() ->with('purchasable') ->get(); // Create ProductPurchase for each cart item foreach ($items as $item) { $product = $item->purchasable; // Lock the product to prevent race conditions on stock if ($product instanceof Product && method_exists($product, 'lockForUpdate')) { $product = $product->lockForUpdate()->find($product->id); } $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::parse($from); } if ($until && is_string($until)) { $until = Carbon::parse($until); } } } // 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 { // 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}" ); } // 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()); } } } // Validate booking products have required dates if ($product instanceof Product && $product->isBooking() && !$product->isPool() && (!$from || !$until)) { throw new \Exception("Booking product '{$product->name}' requires a timespan (from/until dates)."); } $purchase = $this->customer->purchase( $product->prices()->first(), $quantity, null, $from, $until ); $purchase->update([ 'cart_id' => $item->cart_id, ]); // Remove item from cart $item->update([ 'purchase_id' => $purchase->id, ]); } $this->update([ 'converted_at' => now(), 'status' => CartStatus::CONVERTED, ]); // Create an Order from this converted cart $order = Order::createFromCart($this); return $this; }); } /** * Create a Stripe Checkout Session for this cart * * This method: * - Validates the cart (doesn't convert it) * - Creates ProductPurchase records for each cart item (with PENDING status) * - Uses dynamic price_data for each cart item (no pre-created Stripe prices needed) * - Creates line items with descriptions including booking dates * - Returns the Stripe checkout session * * @param array $options Optional session parameters (success_url, cancel_url, etc.) * @param string|null $url Optional fullPath URL for success and cancel URLs * * @return mixed Stripe\Checkout\Session instance * @throws \Exception */ public function checkoutSession(array $options = [], ?string $url = null) { 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(); // 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]); } }); $lineItems = []; foreach ($this->items as $item) { $product = $item->purchasable; // Get product name (use short_description if available, otherwise name) $productName = $product->name ?? 'Product [' . $product->id . ']'; $description = $product->short_description ?? null; // Build description with booking dates if available if ($item->from && $item->until) { $fromFormatted = $item->from->format('M j, Y H:i'); $untilFormatted = $item->until->format('M j, Y H:i'); $description .= " from {$fromFormatted} to {$untilFormatted}"; } // Price is already stored in cents, Stripe expects smallest currency unit $unitAmountCents = (int) $item->price; // Build line item using price_data for dynamic pricing $lineItem = [ 'price_data' => [ 'currency' => config('shop.currency', 'usd'), 'product_data' => [ 'name' => $productName, ...($description ? ['description' => $description] : []), ], 'unit_amount' => $unitAmountCents, ], 'quantity' => $item->quantity, ]; $lineItems[] = $lineItem; } $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; // Prepare session parameters $sessionParams = [ 'payment_method_types' => ['card'], 'line_items' => $lineItems, 'mode' => 'payment', 'success_url' => $success_url, 'cancel_url' => $cancel_url, '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; } } /** * 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 */ public function checkoutSessionLink(array $option = [], ?string $url = null): string|null|false { // Validate cart - throw exceptions if validation fails // This ensures users know what's wrong instead of silently returning null $this->validateForCheckout(); $checkoutSession = $this->checkoutSession($option, $url); if ($checkoutSession) { if ( isset($checkoutSession->url) && !empty($checkoutSession->url) ) { return $checkoutSession->url; } return false; } return null; } }