laravel-shop/src/Models/Cart.php

2145 lines
85 KiB
PHP
Raw Normal View History

2025-11-21 10:49:41 +00:00
<?php
namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable;
use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PurchaseStatus;
2025-12-19 13:26:57 +00:00
use Blax\Shop\Exceptions\CartableInterfaceException;
use Blax\Shop\Exceptions\CartAlreadyConvertedException;
use Blax\Shop\Exceptions\CartDatesRequiredException;
use Blax\Shop\Exceptions\CartEmptyException;
use Blax\Shop\Exceptions\CartItemMissingInformationException;
2025-12-17 11:26:26 +00:00
use Blax\Shop\Exceptions\InvalidDateRangeException;
use Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException;
2025-12-19 13:26:57 +00:00
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Exceptions\PriceCalculationException;
use Blax\Shop\Exceptions\ProductHasNoPriceException;
2025-12-17 08:24:42 +00:00
use Blax\Shop\Services\CartService;
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;
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;
use Illuminate\Support\Facades\DB;
2025-11-21 10:49:41 +00:00
class Cart extends Model
{
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',
'from',
'until',
2025-11-21 10:49:41 +00:00
];
protected $casts = [
'status' => CartStatus::class,
2025-11-21 10:49:41 +00:00
'expires_at' => 'datetime',
'converted_at' => 'datetime',
'last_activity_at' => 'datetime',
'meta' => 'object',
'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()
{
2025-12-29 09:26:51 +00:00
// Auto-update last_activity_at on creation
static::creating(function ($cart) {
if (empty($cart->last_activity_at)) {
$cart->last_activity_at = now();
}
});
2025-12-18 11:21:29 +00:00
static::deleting(function ($cart) {
$cart->items()->delete();
});
}
2025-12-29 09:26:51 +00:00
/**
* Touch the cart's last activity timestamp.
* Call this whenever there's activity on the cart.
*/
public function touchActivity(): self
{
$this->last_activity_at = now();
$this->saveQuietly(); // Don't trigger events
return $this;
}
/**
* Check if the cart has expired.
* Checks both explicit expires_at and activity-based expiration.
*/
public function isExpired(): bool
{
// Check if status is explicitly expired
if ($this->status === CartStatus::EXPIRED) {
return true;
}
// Check if explicitly expired via expires_at column
if ($this->expires_at && $this->expires_at->lt(now())) {
return true;
}
// Check activity-based expiration
$expirationMinutes = config('shop.cart.expiration_minutes', 60);
$lastActivity = $this->last_activity_at ?? $this->updated_at;
return $lastActivity && $lastActivity->lt(now()->subMinutes($expirationMinutes));
}
/**
* Scope to get active (non-expired, non-converted) carts.
*/
public function scopeActive($query)
{
return $query->whereNull('converted_at')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
/**
* Check if the cart should be deleted (unused for more than the configured time).
*/
public function shouldBeDeleted(): bool
{
// Never delete converted carts
if ($this->converted_at || $this->status === CartStatus::CONVERTED) {
return false;
}
$deletionHours = config('shop.cart.deletion_hours', 24);
$lastActivity = $this->last_activity_at ?? $this->updated_at;
return $lastActivity && $lastActivity->lt(now()->subHours($deletionHours));
}
/**
* Mark the cart as expired.
*/
public function markAsExpired(): self
{
$this->status = CartStatus::EXPIRED;
$this->save();
return $this;
}
/**
* Mark the cart as abandoned.
*/
public function markAsAbandoned(): self
{
$this->status = CartStatus::ABANDONED;
$this->save();
return $this;
}
/**
* Scope to get carts that should expire (inactive for more than configured time).
*/
public function scopeShouldExpire($query)
{
$expirationMinutes = config('shop.cart.expiration_minutes', 60);
return $query->where('status', CartStatus::ACTIVE->value)
->where(function ($q) use ($expirationMinutes) {
$q->where('last_activity_at', '<', now()->subMinutes($expirationMinutes))
->orWhere(function ($q2) use ($expirationMinutes) {
$q2->whereNull('last_activity_at')
->where('updated_at', '<', now()->subMinutes($expirationMinutes));
});
});
}
/**
* Scope to get carts that should be deleted (unused for more than configured time).
*/
public function scopeShouldDelete($query)
{
$deletionHours = config('shop.cart.deletion_hours', 24);
return $query->where('status', '!=', CartStatus::CONVERTED->value)
->whereNull('converted_at')
->where(function ($q) use ($deletionHours) {
$q->where('last_activity_at', '<', now()->subHours($deletionHours))
->orWhere(function ($q2) use ($deletionHours) {
$q2->whereNull('last_activity_at')
->where('updated_at', '<', now()->subHours($deletionHours));
});
});
}
/**
* Scope to get carts with expired status.
*/
public function scopeWithExpiredStatus($query)
{
return $query->where('status', CartStatus::EXPIRED->value);
}
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');
}
2025-12-29 08:59:02 +00:00
/**
* 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');
}
2025-11-21 10:49:41 +00:00
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);
}
/**
* 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
{
2025-12-29 10:11:27 +00:00
// Eager load priceModel to avoid N+1 queries
// Note: price() relationship conflicts with price column, so we use explicit loading
$items = $this->items()->get();
// Batch load all price IDs
$priceIds = $items->pluck('price_id')->filter()->unique()->values()->toArray();
if (empty($priceIds)) {
return array_fill(0, $items->count(), null);
}
$prices = ProductPrice::whereIn('id', $priceIds)->pluck('stripe_price_id', 'id');
2025-12-17 11:26:26 +00:00
2025-12-29 10:11:27 +00:00
return $items->map(function ($item) use ($prices) {
return $item->price_id ? ($prices[$item->price_id] ?? null) : null;
2025-12-17 11:26:26 +00:00
})->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();
}
2025-12-17 11:26:26 +00:00
/**
* Set the default date range for the cart.
* Items without specific dates will use these as fallback.
*
2025-12-17 15:43:22 +00:00
* @param \DateTimeInterface|string $from Start date (DateTimeInterface or parsable string)
* @param \DateTimeInterface|string $until End date (DateTimeInterface or parsable string)
2025-12-17 11:26:26 +00:00
* @param bool $validateAvailability Whether to validate product availability for the timespan
* @return $this
* @throws InvalidDateRangeException
* @throws NotEnoughAvailableInTimespanException
*/
2025-12-17 15:50:56 +00:00
public function setDates(
2025-12-19 13:26:57 +00:00
\DateTimeInterface|string|int|float|null $from,
\DateTimeInterface|string|int|float|null $until,
2025-12-19 09:57:26 +00:00
bool $validateAvailability = true,
bool $overwrite_item_dates = true
2025-12-17 15:50:56 +00:00
): self {
2025-12-17 15:43:22 +00:00
// Parse string dates using Carbon
2025-12-19 13:26:57 +00:00
if ($from !== null && (is_string($from) || is_numeric($from))) {
2025-12-17 15:43:22 +00:00
$from = Carbon::parse($from);
}
2025-12-19 13:26:57 +00:00
if ($until !== null && (is_string($until) || is_numeric($until))) {
2025-12-17 15:43:22 +00:00
$until = Carbon::parse($until);
}
2025-12-19 13:26:57 +00:00
// Always update cart dates with provided values
$updateData = [];
if ($from !== null) {
$updateData['from'] = $from;
}
if ($until !== null) {
$updateData['until'] = $until;
2025-12-17 11:26:26 +00:00
}
2025-12-19 13:26:57 +00:00
if (!empty($updateData)) {
$this->update($updateData);
$this->refresh();
2025-12-17 11:26:26 +00:00
}
2025-12-19 13:26:57 +00:00
// Get the current dates (may include one from database if only one was updated)
$effectiveFrom = $from ?? $this->from;
$effectiveUntil = $until ?? $this->until;
// Only calculate/validate if BOTH dates are set
if ($effectiveFrom && $effectiveUntil) {
// For calculations, swap if dates are backwards
$calcFrom = $effectiveFrom;
$calcUntil = $effectiveUntil;
if ($effectiveFrom > $effectiveUntil) {
$calcFrom = $effectiveUntil;
$calcUntil = $effectiveFrom;
}
2025-12-17 11:26:26 +00:00
2025-12-19 13:26:57 +00:00
if ($validateAvailability) {
// Validate against the correctly ordered dates
$this->validateDateAvailability($calcFrom, $calcUntil, $overwrite_item_dates);
}
// Update cart items with correctly ordered dates
$this->applyDatesToItems(
$validateAvailability,
$overwrite_item_dates,
$calcFrom,
$calcUntil
);
}
2025-12-18 14:33:47 +00:00
2025-12-17 11:26:26 +00:00
return $this->fresh();
}
/**
* Set the 'from' date for the cart.
*
2025-12-17 15:43:22 +00:00
* @param \DateTimeInterface|string $from Start date (DateTimeInterface or parsable string)
2025-12-17 11:26:26 +00:00
* @param bool $validateAvailability Whether to validate product availability for the timespan
* @return $this
* @throws NotEnoughAvailableInTimespanException
*/
2025-12-17 15:50:56 +00:00
public function setFromDate(
\DateTimeInterface|string|int|float $from,
bool $validateAvailability = true
): self {
2025-12-17 15:43:22 +00:00
// Parse string dates using Carbon
2025-12-17 15:50:56 +00:00
if (is_string($from) || is_numeric($from)) {
2025-12-17 15:43:22 +00:00
$from = Carbon::parse($from);
}
2025-12-19 13:26:57 +00:00
// Always update the from date
$this->update(['from' => $from]);
$this->refresh();
// Only calculate if both dates are set
if ($this->until) {
// For calculations, swap if dates are backwards
$calcFrom = $from;
$calcUntil = $this->until;
if ($from > $this->until) {
$calcFrom = $this->until;
$calcUntil = $from;
}
2025-12-17 11:26:26 +00:00
2025-12-19 13:26:57 +00:00
if ($validateAvailability) {
$this->validateDateAvailability($calcFrom, $calcUntil);
}
2025-12-20 14:08:08 +00:00
// Update cart items with new dates and recalculate prices
$this->applyDatesToItems(
$validateAvailability,
true,
$calcFrom,
$calcUntil
);
2025-12-17 11:26:26 +00:00
}
return $this->fresh();
}
/**
* Set the 'until' date for the cart.
*
2025-12-17 15:43:22 +00:00
* @param \DateTimeInterface|string $until End date (DateTimeInterface or parsable string)
2025-12-17 11:26:26 +00:00
* @param bool $validateAvailability Whether to validate product availability for the timespan
* @return $this
* @throws NotEnoughAvailableInTimespanException
*/
2025-12-17 15:50:56 +00:00
public function setUntilDate(\DateTimeInterface|string|int|float $until, bool $validateAvailability = true): self
2025-12-17 11:26:26 +00:00
{
2025-12-17 15:43:22 +00:00
// Parse string dates using Carbon
2025-12-17 15:50:56 +00:00
if (is_string($until) || is_numeric($until)) {
2025-12-17 15:43:22 +00:00
$until = Carbon::parse($until);
}
2025-12-19 13:26:57 +00:00
// Always update the until date
$this->update(['until' => $until]);
$this->refresh();
// Only calculate if both dates are set
if ($this->from) {
// For calculations, swap if dates are backwards
$calcFrom = $this->from;
$calcUntil = $until;
if ($this->from > $until) {
$calcFrom = $until;
$calcUntil = $this->from;
}
2025-12-17 11:26:26 +00:00
2025-12-19 13:26:57 +00:00
if ($validateAvailability) {
$this->validateDateAvailability($calcFrom, $calcUntil);
}
2025-12-20 14:08:08 +00:00
// Update cart items with new dates and recalculate prices
$this->applyDatesToItems(
$validateAvailability,
true,
$calcFrom,
$calcUntil
);
2025-12-17 11:26:26 +00:00
}
return $this->fresh();
}
/**
* Apply cart dates to all items that don't have their own dates set.
*
* @param bool $validateAvailability Whether to validate product availability for the timespan
2025-12-19 09:08:24 +00:00
* @param bool $overwrite If true, overwrites existing item dates. If false, only sets null fields.
2025-12-19 13:26:57 +00:00
* @param \DateTimeInterface|null $from Optional from date (uses cart's from if not provided)
* @param \DateTimeInterface|null $until Optional until date (uses cart's until if not provided)
2025-12-17 11:26:26 +00:00
* @return $this
* @throws NotEnoughAvailableInTimespanException
*/
2025-12-19 13:26:57 +00:00
public function applyDatesToItems(
bool $validateAvailability = true,
bool $overwrite = false,
?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null
): self {
// Use provided dates or fall back to cart dates
$fromDate = $from ?? $this->from;
$untilDate = $until ?? $this->until;
if (!$fromDate || !$untilDate) {
2025-12-17 11:26:26 +00:00
return $this;
}
2025-12-20 10:22:04 +00:00
// First, reallocate pool items if pricing strategy suggests better allocation with new dates
$this->reallocatePoolItems($fromDate, $untilDate, $overwrite);
// Refresh items relationship to get updated meta values
$this->load('items');
// Track pool products to validate total allocation across all cart items
$poolValidation = [];
2025-12-17 11:26:26 +00:00
foreach ($this->items as $item) {
2025-12-19 09:08:24 +00:00
// Only apply to booking items
if ($item->is_booking) {
// Determine which dates to apply based on overwrite setting
$shouldApplyFrom = $overwrite || !$item->from;
$shouldApplyUntil = $overwrite || !$item->until;
if (!$shouldApplyFrom && !$shouldApplyUntil) {
continue;
}
2025-12-19 13:26:57 +00:00
$itemFrom = $shouldApplyFrom ? $fromDate : $item->from;
$itemUntil = $shouldApplyUntil ? $untilDate : $item->until;
2025-12-19 09:08:24 +00:00
2025-12-17 11:26:26 +00:00
if ($validateAvailability) {
$product = $item->purchasable;
2025-12-20 10:22:04 +00:00
2025-12-20 11:19:34 +00:00
// For pool products, check if allocated by reallocatePoolItems
2025-12-20 10:22:04 +00:00
if ($product instanceof Product && $product->isPool()) {
$allocatedSingleItemId = $item->product_id;
2025-12-20 11:19:34 +00:00
// If this item was NOT allocated (no single assigned), skip updateDates
// to preserve the null price set by reallocatePoolItems
if (empty($allocatedSingleItemId)) {
// Just update the dates without recalculating price
$item->update([
'from' => $itemFrom,
'until' => $itemUntil,
]);
continue;
}
2025-12-20 10:22:04 +00:00
$poolKey = $product->id . '|' . $itemFrom->format('Y-m-d H:i:s') . '|' . $itemUntil->format('Y-m-d H:i:s');
if (!isset($poolValidation[$poolKey])) {
$poolValidation[$poolKey] = [
'product' => $product,
'from' => $itemFrom,
'until' => $itemUntil,
'requested' => 0,
'allocated' => 0,
];
}
$poolValidation[$poolKey]['requested'] += $item->quantity;
2025-12-20 11:19:34 +00:00
$poolValidation[$poolKey]['allocated'] += $item->quantity;
2025-12-20 10:22:04 +00:00
} elseif ($product && !$product->isAvailableForBooking($itemFrom, $itemUntil, $item->quantity)) {
2025-12-20 11:19:34 +00:00
// Non-pool booking item is not available - mark as unavailable
// Don't throw exception - let user adjust dates freely
$item->update([
'from' => $itemFrom,
'until' => $itemUntil,
'price' => null,
'subtotal' => null,
'unit_amount' => null,
]);
// Skip updateDates() since we already set the dates with null price
continue;
2025-12-17 11:26:26 +00:00
}
}
2025-12-19 13:26:57 +00:00
$item->updateDates($itemFrom, $itemUntil);
2025-12-17 11:26:26 +00:00
}
}
2025-12-20 11:19:34 +00:00
// Pool validation is now handled by reallocatePoolItems() which marks
// unallocated items with null price instead of throwing exceptions.
// This allows users to freely adjust dates without exceptions.
// Validation happens at checkout time via isReadyForCheckout().
2025-12-20 10:22:04 +00:00
2025-12-17 11:26:26 +00:00
return $this->fresh();
}
2025-12-20 10:22:04 +00:00
/**
* Reallocate pool items to optimize pricing when dates change.
*
* When dates change, check if better-priced single items become available
* according to the pool's pricing strategy (LOWEST, HIGHEST, etc.)
*
* @param \DateTimeInterface $from New start date
* @param \DateTimeInterface $until New end date
* @param bool $overwrite Whether to apply to all items or only those without dates
* @return void
*/
protected function reallocatePoolItems(\DateTimeInterface $from, \DateTimeInterface $until, bool $overwrite = true): void
{
// Group cart items by pool product
$poolItems = $this->items()->get()
->filter(function ($item) {
$product = $item->purchasable;
return $product instanceof Product && $product->isPool();
})
->groupBy('purchasable_id');
foreach ($poolItems as $poolId => $items) {
$poolProduct = $items->first()->purchasable;
if (!$poolProduct) {
continue;
}
// Get all available single items for the new dates with their prices
$strategy = $poolProduct->getPricingStrategy();
// Eager load stocks relationship to ensure fresh data
$singleItems = $poolProduct->singleProducts()->with('stocks')->get();
if ($singleItems->isEmpty()) {
continue;
}
2025-12-20 14:08:08 +00:00
// Build list of available singles with their prices for new dates
$singlesWithPrices = [];
2025-12-20 10:22:04 +00:00
foreach ($singleItems as $single) {
2025-12-20 14:08:08 +00:00
// Get available stock at the booking start date
// This already accounts for claims via the DECREASE entries they create
$effectiveAvailable = $single->getAvailableStock($from);
2025-12-20 10:22:04 +00:00
2025-12-20 14:08:08 +00:00
if ($effectiveAvailable > 0) {
2025-12-20 10:22:04 +00:00
$priceModel = $single->defaultPrice()->first();
$price = $priceModel?->getCurrentPrice($single->isOnSale());
// Fallback to pool price if single has no price
if ($price === null && $poolProduct->hasPrice()) {
$priceModel = $poolProduct->defaultPrice()->first();
$price = $priceModel?->getCurrentPrice($poolProduct->isOnSale());
}
if ($price !== null) {
2025-12-20 14:08:08 +00:00
$singlesWithPrices[] = [
2025-12-20 10:22:04 +00:00
'single' => $single,
'price' => $price,
'price_id' => $priceModel?->id,
2026-01-05 08:07:09 +00:00
'currency' => $priceModel?->currency,
2025-12-20 14:08:08 +00:00
'available' => $effectiveAvailable,
2025-12-20 10:22:04 +00:00
];
}
}
}
2025-12-20 14:08:08 +00:00
if (empty($singlesWithPrices)) {
2025-12-20 11:19:34 +00:00
// No singles available for this period - mark ALL pool items as unavailable
foreach ($items as $cartItem) {
// Only update if we should overwrite or item has no dates yet
if (!$overwrite && $cartItem->from && $cartItem->until) {
continue;
}
// Clear allocation and set price to null to indicate unavailable
$cartItem->update([
'product_id' => null,
2025-12-20 11:19:34 +00:00
'price' => null,
'subtotal' => null,
'unit_amount' => null,
]);
}
2025-12-20 10:22:04 +00:00
continue;
}
// Sort by pricing strategy
2025-12-20 14:08:08 +00:00
usort($singlesWithPrices, function ($a, $b) use ($strategy) {
2025-12-20 10:22:04 +00:00
return match ($strategy) {
\Blax\Shop\Enums\PricingStrategy::LOWEST => $a['price'] <=> $b['price'],
\Blax\Shop\Enums\PricingStrategy::HIGHEST => $b['price'] <=> $a['price'],
\Blax\Shop\Enums\PricingStrategy::AVERAGE => 0,
};
});
// Reallocate cart items to optimal singles
2025-12-20 14:08:08 +00:00
// Track usage per single to properly allocate considering quantities
// If a single can't accommodate a cart item's full quantity, split the cart item
$singleUsage = []; // single_id => quantity used
// Use singlesWithPrices directly as our ordered list
$orderedSingles = $singlesWithPrices;
2025-12-20 10:22:04 +00:00
foreach ($items as $cartItem) {
// Only reallocate if we should overwrite or item has no dates yet
if (!$overwrite && $cartItem->from && $cartItem->until) {
continue;
}
2025-12-20 14:08:08 +00:00
$neededQty = $cartItem->quantity;
2025-12-20 10:22:04 +00:00
$allocated = false;
2025-12-20 14:08:08 +00:00
// Try to find a single that can accommodate the full quantity
foreach ($orderedSingles as $singleInfo) {
$single = $singleInfo['single'];
$usedFromSingle = $singleUsage[$single->id] ?? 0;
$remainingFromSingle = $singleInfo['available'] - $usedFromSingle;
if ($remainingFromSingle >= $neededQty) {
// This single can accommodate the cart item's full quantity
// Update product_id to track the allocated single item
$updates = ['product_id' => $single->id];
if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) {
$updates['price_id'] = $singleInfo['price_id'];
}
2026-01-05 08:07:09 +00:00
if ($singleInfo['currency']) {
$updates['currency'] = $singleInfo['currency'];
2025-12-20 10:22:04 +00:00
}
2026-01-05 08:07:09 +00:00
$cartItem->update($updates);
2025-12-20 10:22:04 +00:00
2025-12-20 14:08:08 +00:00
// Track usage
$singleUsage[$single->id] = $usedFromSingle + $neededQty;
2025-12-20 10:22:04 +00:00
$allocated = true;
break;
}
}
if (!$allocated) {
2025-12-20 14:08:08 +00:00
// No single can accommodate the full quantity
// Try to split: use as much as possible from the first available single,
// then create new cart items for the rest
$remainingQty = $neededQty;
$firstAllocation = true;
foreach ($orderedSingles as $singleInfo) {
if ($remainingQty <= 0) break;
$single = $singleInfo['single'];
$usedFromSingle = $singleUsage[$single->id] ?? 0;
$availableFromSingle = $singleInfo['available'] - $usedFromSingle;
if ($availableFromSingle <= 0) continue;
$qtyToAllocate = min($remainingQty, $availableFromSingle);
if ($firstAllocation) {
// Update the original cart item with reduced quantity
// Also update subtotal to match the new quantity
$newSubtotal = $cartItem->price * $qtyToAllocate;
$updates = [
2025-12-20 14:08:08 +00:00
'quantity' => $qtyToAllocate,
'subtotal' => $newSubtotal,
'product_id' => $single->id,
];
2025-12-20 14:08:08 +00:00
if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) {
$updates['price_id'] = $singleInfo['price_id'];
2025-12-20 14:08:08 +00:00
}
2026-01-05 08:07:09 +00:00
if ($singleInfo['currency']) {
$updates['currency'] = $singleInfo['currency'];
}
$cartItem->update($updates);
$cartItem->refresh(); // Ensure model reflects database state
2025-12-20 14:08:08 +00:00
$firstAllocation = false;
} else {
// Create a new cart item for the additional quantity
2026-01-05 08:07:09 +00:00
// Use the price info from singleInfo (already calculated with pool fallback)
$singlePrice = $singleInfo['price'];
$priceId = $singleInfo['price_id'];
$currency = $singleInfo['currency'];
2025-12-20 14:08:08 +00:00
$days = $this->calculateBookingDays($from, $until);
$pricePerUnit = (int) round($singlePrice * $days);
$newCartItem = $this->items()->create([
'purchasable_id' => $cartItem->purchasable_id,
'purchasable_type' => $cartItem->purchasable_type,
'product_id' => $single->id,
2026-01-05 08:07:09 +00:00
'price_id' => $priceId,
2025-12-20 14:08:08 +00:00
'quantity' => $qtyToAllocate,
2026-01-05 08:07:09 +00:00
'currency' => $currency,
2025-12-20 14:08:08 +00:00
'price' => $pricePerUnit,
'regular_price' => $pricePerUnit,
'unit_amount' => (int) round($singlePrice),
'subtotal' => $pricePerUnit * $qtyToAllocate,
'parameters' => $cartItem->parameters,
'from' => $from,
'until' => $until,
]);
}
$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->update([
'product_id' => null,
2025-12-20 14:08:08 +00:00
'price' => null,
'subtotal' => null,
'unit_amount' => null,
]);
} else {
// Partial allocation - the cart item was already updated with what we could allocate
// The remaining quantity is lost (over-capacity)
}
}
2025-12-20 10:22:04 +00:00
}
}
}
}
2025-12-17 11:26:26 +00:00
/**
* Validate that all booking items in the cart are available for the given timespan.
*
* @param \DateTimeInterface $from Start date
* @param \DateTimeInterface $until End date
* @return void
* @throws NotEnoughAvailableInTimespanException
*/
2025-12-20 11:19:34 +00:00
/**
* Mark booking items as unavailable if they cannot be booked for the given dates.
* Instead of throwing exceptions, this marks items with null price.
*
* @param \DateTimeInterface $from Start date
* @param \DateTimeInterface $until End date
* @param bool $useProvidedDates Whether to use provided dates or item's own dates
* @return void
*/
2025-12-19 11:25:59 +00:00
protected function validateDateAvailability(\DateTimeInterface $from, \DateTimeInterface $until, bool $useProvidedDates = false): void
2025-12-17 11:26:26 +00:00
{
foreach ($this->items as $item) {
if (!$item->is_booking) {
continue;
}
$product = $item->purchasable;
if (!$product) {
continue;
}
2025-12-20 11:19:34 +00:00
// Skip pool products - they are handled by reallocatePoolItems()
if ($product->type === ProductType::POOL) {
continue;
}
2025-12-19 11:25:59 +00:00
// Use provided dates when validating date overwrites, otherwise use item's specific dates
$checkFrom = $useProvidedDates ? $from : ($item->from ?? $from);
$checkUntil = $useProvidedDates ? $until : ($item->until ?? $until);
2025-12-17 11:26:26 +00:00
if (!$product->isAvailableForBooking($checkFrom, $checkUntil, $item->quantity)) {
2025-12-20 11:19:34 +00:00
// Mark item as unavailable instead of throwing exception
// This allows users to freely adjust dates
$item->update([
'price' => null,
'subtotal' => null,
'unit_amount' => null,
]);
2025-12-17 11:26:26 +00:00
}
}
}
2025-12-18 11:21:29 +00:00
/**
* Scope to find abandoned carts
* Carts that are active but haven't been updated recently
*/
public function scopeAbandoned($query, $inactiveMinutes = 60)
{
return $query->where('status', CartStatus::ACTIVE)
->where('last_activity_at', '<', now()->subMinutes($inactiveMinutes));
}
2025-11-29 11:05:02 +00:00
public function getUnpaidAmount(): float
{
$paidAmount = $this->purchases()
->whereColumn('total_amount', '!=', 'amount_paid')
->sum('total_amount');
return max(0, $this->getTotal() - $paidAmount);
}
public function getPaidAmount(): float
{
return $this->purchases()
->whereColumn('total_amount', '!=', 'amount_paid')
->sum('total_amount');
}
2025-11-21 10:49:41 +00:00
public function isConverted(): bool
{
return !is_null($this->converted_at);
}
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
* @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
*/
public function addToCart(
2025-11-29 11:05:02 +00:00
Model $cartable,
2025-12-09 08:42:59 +00:00
int $quantity = 1,
array $parameters = [],
2025-12-24 18:40:10 +00:00
null|\DateTimeInterface $from = null,
null|\DateTimeInterface $until = null
2025-11-29 11:05:02 +00:00
): CartItem {
// $cartable must implement Cartable
if (! $cartable instanceof Cartable) {
2025-12-19 13:26:57 +00:00
throw new CartableInterfaceException();
}
2025-12-24 18:40:10 +00:00
if ($cartable instanceof Product) {
$is_pool = $cartable->isPool();
$is_booking = $cartable->isBooking();
} elseif (
$cartable instanceof ProductPrice
&& $cartable->purchasable instanceof Product
) {
$is_pool = $cartable->purchasable->isPool();
$is_booking = $cartable->purchasable->isBooking();
}
2025-12-26 07:42:59 +00:00
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;
}
}
2025-12-24 18:40:10 +00:00
2025-12-16 12:58:03 +00:00
// For pool products with quantity > 1, add them one at a time to get progressive pricing
2025-12-24 18:40:10 +00:00
if ($is_pool && $quantity > 1) {
2025-12-20 10:22:04 +00:00
// Validate availability if dates are provided
2025-12-16 12:58:03 +00:00
if ($from && $until) {
$available = $cartable->getPoolMaxQuantity($from, $until);
2025-12-20 10:22:04 +00:00
// Subtract items already in cart for the same period
$itemsInCart = $this->items()
->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable))
->get()
->filter(function ($item) use ($from, $until) {
// Only count items with overlapping dates
if (!$item->from || !$item->until) {
return false;
}
// Check for overlap: item overlaps if it doesn't end before period starts or start after period ends
return !($item->until < $from || $item->from > $until);
})
->sum('quantity');
$availableForThisRequest = $available === PHP_INT_MAX ? PHP_INT_MAX : max(0, $available - $itemsInCart);
if ($availableForThisRequest !== PHP_INT_MAX && $quantity > $availableForThisRequest) {
2025-12-19 13:26:57 +00:00
throw new NotEnoughStockException(
2025-12-20 10:22:04 +00:00
"Pool product '{$cartable->name}' has only {$availableForThisRequest} items available for the requested period. Requested: {$quantity}"
2025-12-16 12:58:03 +00:00
);
}
2025-12-20 11:19:34 +00:00
} else {
// When dates are not provided, validate against total pool capacity (not current availability)
// This allows adding items even if currently claimed - dates will be validated later
$totalCapacity = $cartable->getPoolTotalCapacity(); // Total capacity ignoring claims
// Subtract items already in cart
$itemsInCart = $this->items()
->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable))
->sum('quantity');
2025-12-24 18:40:10 +00:00
$availableForThisRequest = $totalCapacity === PHP_INT_MAX
? PHP_INT_MAX
: max(0, $totalCapacity - $itemsInCart);
2025-12-20 11:19:34 +00:00
if ($availableForThisRequest !== PHP_INT_MAX && $quantity > $availableForThisRequest) {
throw new NotEnoughStockException(
"Pool product '{$cartable->name}' has only {$availableForThisRequest} items available. Requested: {$quantity}"
);
}
2025-12-16 12:58:03 +00:00
}
2025-12-17 08:24:42 +00:00
2025-12-16 12:58:03 +00:00
// Add items one at a time for progressive pricing
$lastCartItem = null;
for ($i = 0; $i < $quantity; $i++) {
$lastCartItem = $this->addToCart($cartable, 1, $parameters, $from, $until);
}
return $lastCartItem;
}
// 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
if ($from && $until) {
// Validate from is before until
if ($from >= $until) {
2025-12-19 13:26:57 +00:00
throw new InvalidDateRangeException("The 'from' date must be before the 'until' date. Got from: {$from->format('Y-m-d H:i:s')}, until: {$until->format('Y-m-d H:i:s')}");
}
2025-12-28 10:12:58 +00:00
// For booking products (non-pool), validate against total stock capacity
// Date-based validation will happen at checkout
2025-12-24 18:40:10 +00:00
if (
$is_booking
&& !$is_pool
2025-12-28 10:12:58 +00:00
&& $cartable->manage_stock
2025-12-24 18:40:10 +00:00
) {
2025-12-28 10:12:58 +00:00
$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}"
);
}
}
2025-12-28 10:12:58 +00:00
// 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
2025-12-24 18:40:10 +00:00
if ($is_pool) {
2025-12-28 10:12:58 +00:00
$totalCapacity = $cartable->getPoolTotalCapacity(); // Total capacity ignoring claims
2025-12-20 10:22:04 +00:00
2025-12-28 10:12:58 +00:00
// Subtract items already in cart for this pool
2025-12-20 10:22:04 +00:00
$itemsInCart = $this->items()
->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable))
->sum('quantity');
2025-12-28 10:12:58 +00:00
$availableForThisRequest = $totalCapacity === PHP_INT_MAX ? PHP_INT_MAX : max(0, $totalCapacity - $itemsInCart);
2025-12-20 10:22:04 +00:00
2025-12-28 10:12:58 +00:00
// Only prevent adding if it exceeds total pool capacity
2025-12-20 10:22:04 +00:00
if ($availableForThisRequest !== PHP_INT_MAX && $quantity > $availableForThisRequest) {
2025-12-19 13:26:57 +00:00
throw new NotEnoughStockException(
2025-12-28 10:12:58 +00:00
"Pool product '{$cartable->name}' has only {$availableForThisRequest} items available. Requested: {$quantity}"
);
}
}
} elseif ($from || $until) {
// If only one date is provided, it's an error
2025-12-19 13:26:57 +00:00
throw new CartDatesRequiredException();
} else {
2025-12-20 11:19:34 +00:00
// When adding pool items without dates, validate against total pool capacity
// This allows adding items even if currently claimed - date-based validation happens later
2025-12-24 18:40:10 +00:00
if ($is_pool) {
2025-12-20 11:19:34 +00:00
$totalCapacity = $cartable->getPoolTotalCapacity(); // Total capacity ignoring claims
2025-12-20 11:19:34 +00:00
// Subtract items already in cart (without dates or with any dates)
$itemsInCart = $this->items()
->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable))
->sum('quantity');
$availableForThisRequest = $totalCapacity === PHP_INT_MAX ? PHP_INT_MAX : max(0, $totalCapacity - $itemsInCart);
if ($availableForThisRequest !== PHP_INT_MAX && $quantity > $availableForThisRequest) {
throw new NotEnoughStockException(
"Pool product '{$cartable->name}' has only {$availableForThisRequest} items available. Requested: {$quantity}"
);
}
}
// Items may be claimed now but available in the future
// Full date-based validation will happen when dates are set via setDates() or at checkout
}
}
2025-12-16 12:58:03 +00:00
// For pool products, calculate current quantity in cart once to ensure consistency
// Force fresh query to get latest cart state (important for recursive calls)
$currentQuantityInCart = null;
2025-12-19 09:57:26 +00:00
$poolSingleItem = null;
$poolPriceId = null;
2025-12-24 18:40:10 +00:00
if ($is_pool) {
2025-12-16 12:58:03 +00:00
$this->unsetRelation('items'); // Clear cached relationship
$currentQuantityInCart = $this->items()
->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable))
->sum('quantity');
2025-12-19 09:57:26 +00:00
// Pre-calculate pool pricing info for use in merge logic
$poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until);
if ($poolItemData) {
$poolSingleItem = $poolItemData['item'];
$poolPriceId = $poolItemData['price_id'];
}
2025-12-16 12:58:03 +00:00
}
// Check if item already exists in cart with same parameters, dates, AND price
2025-12-09 08:42:59 +00:00
$existingItem = $this->items()
->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable))
->get()
2025-12-24 18:40:10 +00:00
->first(function ($item) use ($parameters, $from, $until, $cartable, $poolPriceId, $is_pool) {
2025-12-09 08:42:59 +00:00
$existingParams = is_array($item->parameters)
? $item->parameters
: (array) $item->parameters;
// Sort both arrays to ensure consistent comparison
ksort($existingParams);
ksort($parameters);
// Check parameters match
$paramsMatch = $existingParams === $parameters;
// Check dates match (important for bookings)
$datesMatch = true;
if ($from || $until) {
$datesMatch = (
($item->from?->format('Y-m-d H:i:s') === $from?->format('Y-m-d H:i:s')) &&
($item->until?->format('Y-m-d H:i:s') === $until?->format('Y-m-d H:i:s'))
);
}
2025-12-19 11:47:55 +00:00
// For pool products, check if we should merge with existing items
// Pool items can ONLY merge if they are from the SAME single item
// This is critical because different single items have their own stock limits
// even if they happen to share the same price (e.g., via pool fallback price)
$priceMatch = true;
2025-12-24 18:40:10 +00:00
if ($is_pool) {
2025-12-19 09:57:26 +00:00
// Calculate expected price for this item
$poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until);
$expectedPrice = $poolItemData['price'] ?? null;
2025-12-19 11:47:55 +00:00
$expectedSingleItemId = $poolItemData['item']?->id ?? null;
2025-12-19 11:25:59 +00:00
// Get the allocated single item ID from the cart item's product_id column
$existingAllocatedItemId = $item->product_id;
2025-12-19 11:47:55 +00:00
// Only merge if:
// 1. price_id matches (same price source)
// 2. actual price amount matches
// 3. allocated single item matches (CRITICAL: same single item being used)
2025-12-19 11:25:59 +00:00
$priceMatch = $poolPriceId && $item->price_id === $poolPriceId &&
2025-12-19 11:47:55 +00:00
$expectedPrice !== null && $item->unit_amount === (int) round($expectedPrice) &&
$expectedSingleItemId !== null && $existingAllocatedItemId === $expectedSingleItemId;
}
return $paramsMatch && $datesMatch && $priceMatch;
2025-12-09 08:42:59 +00:00
});
// Calculate price per day (base price)
2025-12-16 12:58:03 +00:00
// For pool products, get price based on how many items are already in cart
2025-12-24 18:40:10 +00:00
if ($is_pool) {
2025-12-17 09:41:52 +00:00
// Use smarter pricing that considers which price tiers are used
2025-12-17 17:33:34 +00:00
$poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until);
if ($poolItemData) {
$pricePerDay = $poolItemData['price'];
$poolSingleItem = $poolItemData['item'];
$poolPriceId = $poolItemData['price_id'];
} else {
$pricePerDay = null;
}
// Get regular price (non-sale) for comparison
$regularPoolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, false, $from, $until);
$regularPricePerDay = $regularPoolItemData['price'] ?? $pricePerDay;
2025-12-17 08:24:42 +00:00
2025-12-16 12:58:03 +00:00
// If no price found from pool items, try the pool's direct price as fallback
if ($pricePerDay === null && $cartable->hasPrice()) {
2025-12-17 17:33:34 +00:00
$priceModel = $cartable->defaultPrice()->first();
$pricePerDay = $priceModel?->getCurrentPrice($cartable->isOnSale());
$regularPricePerDay = $priceModel?->getCurrentPrice(false) ?? $pricePerDay;
$poolPriceId = $priceModel?->id;
2025-12-28 11:19:56 +00:00
// Still try to find a single item for allocation even with pool's direct price
2026-01-05 08:07:09 +00:00
// This ensures product_id is always set for pool items
2025-12-28 11:19:56 +00:00
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))
->where('product_id', $single->id)
2025-12-28 11:19:56 +00:00
->sum('quantity');
if ($available === PHP_INT_MAX || $inCart < $available) {
$poolSingleItem = $single;
break;
}
}
}
}
2025-12-16 12:58:03 +00:00
}
} else {
$pricePerDay = $cartable->getCurrentPrice();
$regularPricePerDay = $cartable->getCurrentPrice(false) ?? $pricePerDay;
}
// Ensure prices are not null
if ($pricePerDay === null) {
2025-12-24 18:40:10 +00:00
if ($is_pool) {
2025-12-17 15:43:22 +00:00
// For pool products, throw specific error when neither pool nor single items have prices
throw \Blax\Shop\Exceptions\HasNoPriceException::poolProductNoPriceAndNoSingleItemPrices($cartable->name);
2025-12-16 12:58:03 +00:00
}
2025-12-19 13:26:57 +00:00
throw new ProductHasNoPriceException($cartable->name);
}
// 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-18 09:54:42 +00:00
// Calculate price per unit for the entire period and round to nearest cent for consistency
2025-12-24 18:40:10 +00:00
if ($is_booking) {
// For bookings, price scales with days
$pricePerUnit = (int) round($pricePerDay * $days);
$regularPricePerUnit = (int) round($regularPricePerDay * $days);
} else {
// For non-bookings, price is per unit regardless of days
$pricePerUnit = (int) round($pricePerDay);
$regularPricePerUnit = (int) round($regularPricePerDay);
}
2025-12-16 12:58:03 +00:00
// Defensive check - ensure pricePerUnit is not null
if ($pricePerUnit === null) {
2025-12-19 13:26:57 +00:00
throw new PriceCalculationException($cartable->name, $pricePerDay, $days);
2025-12-16 12:58:03 +00:00
}
2025-12-18 11:21:29 +00:00
// Store the base unit_amount (price for 1 quantity, 1 day) in cents
$unitAmount = (int) round($pricePerDay);
// 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,
'subtotal' => $pricePerUnit * $newQuantity,
2025-12-09 08:42:59 +00:00
]);
return $existingItem->fresh();
}
2026-01-05 08:07:09 +00:00
// Determine price_id and currency for the cart item
2025-12-17 11:26:26 +00:00
$priceId = null;
2026-01-05 08:07:09 +00:00
$currency = null;
2025-12-17 11:26:26 +00:00
if ($cartable instanceof Product) {
2026-01-05 08:07:09 +00:00
// For pool products, use the single item's price_id and currency
2025-12-24 18:40:10 +00:00
if ($is_pool && $poolPriceId) {
2025-12-17 17:33:34 +00:00
$priceId = $poolPriceId;
2026-01-05 08:07:09 +00:00
// Get currency from poolItemData if available
$poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until);
$currency = $poolItemData['currency'] ?? null;
2025-12-17 17:33:34 +00:00
} else {
// Get the default price for the product
$defaultPrice = $cartable->defaultPrice()->first();
$priceId = $defaultPrice?->id;
2026-01-05 08:07:09 +00:00
$currency = $defaultPrice?->currency;
2025-12-17 17:33:34 +00:00
}
2025-12-17 11:26:26 +00:00
} elseif ($cartable instanceof \Blax\Shop\Models\ProductPrice) {
2026-01-05 08:07:09 +00:00
// If adding a ProductPrice directly, use its ID and currency
2025-12-17 11:26:26 +00:00
$priceId = $cartable->id;
2026-01-05 08:07:09 +00:00
$currency = $cartable->currency;
2025-12-17 11:26:26 +00:00
}
2025-12-09 08:42:59 +00:00
// Create new cart item
$cartItem = $this->items()->create([
'purchasable_id' => $cartable->getKey(),
'purchasable_type' => get_class($cartable),
'product_id' => ($cartable instanceof Product && $cartable->isPool() && $poolSingleItem) ? $poolSingleItem->id : null,
2025-12-17 11:26:26 +00:00
'price_id' => $priceId,
'quantity' => $quantity,
2026-01-05 08:07:09 +00:00
'currency' => $currency,
'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)
'subtotal' => $totalPrice, // Total for all units
'parameters' => $parameters,
2025-12-26 07:42:59 +00:00
'from' => ($is_booking) ? $from : null,
'until' => ($is_booking) ? $until : null,
]);
2025-12-29 09:26:51 +00:00
// Touch activity timestamp
$this->touchActivity();
2025-12-16 12:58:03 +00:00
return $cartItem;
}
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();
}
}
2025-12-29 09:26:51 +00:00
// Touch activity timestamp
$this->touchActivity();
2025-12-09 09:30:53 +00:00
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
*/
2025-12-18 14:33:47 +00:00
public function validateForCheckout(bool $throws = true): bool
2025-11-29 11:05:02 +00:00
{
// Check if cart is already converted
if ($this->isConverted()) {
if ($throws) {
2025-12-19 13:26:57 +00:00
throw new CartAlreadyConvertedException();
} else {
return false;
}
}
2025-11-29 11:05:02 +00:00
$items = $this->items()
->with('purchasable')
->get();
if ($items->isEmpty()) {
2025-12-18 14:33:47 +00:00
if ($throws) {
2025-12-19 13:26:57 +00:00
throw new CartEmptyException();
2025-12-18 14:33:47 +00:00
} else {
return false;
}
2025-11-29 11:05:02 +00:00
}
// Validate that all items have required information before checkout
foreach ($items as $item) {
$adjustments = $item->requiredAdjustments();
if (!empty($adjustments)) {
$product = $item->purchasable;
$productName = $product ? $product->name : 'Unknown Product';
$missingFields = implode(', ', array_keys($adjustments));
2025-12-18 14:33:47 +00:00
if ($throws) {
2025-12-19 13:26:57 +00:00
throw new CartItemMissingInformationException($productName, $missingFields);
2025-12-18 14:33:47 +00:00
} else {
return false;
}
}
}
2025-12-18 14:33:47 +00:00
// Validate stock availability for all items
foreach ($items as $item) {
$product = $item->purchasable;
if (!($product instanceof Product)) {
continue;
}
2025-12-19 13:26:57 +00:00
// Use effective dates (item-specific or cart fallback)
$from = $item->getEffectiveFromDate();
$until = $item->getEffectiveUntilDate();
// For pool products, check pool availability
if ($product->isPool()) {
if ($from && $until) {
// Get available quantity considering existing cart items and pending purchases
$available = $product->getPoolMaxQuantity($from, $until);
// Calculate how much of this cart's items are already counted
// We need to check if there's still enough stock for what's in this cart
$cartItemsForPool = $items->filter(
fn($i) =>
$i->purchasable_id === $product->id &&
$i->purchasable_type === get_class($product)
);
$totalInCart = $cartItemsForPool->sum('quantity');
if ($available !== PHP_INT_MAX && $totalInCart > $available) {
if ($throws) {
2025-12-19 13:26:57 +00:00
throw new NotEnoughStockException(
"Pool product '{$product->name}' has only {$available} items available for the period " .
"{$from->format('Y-m-d')} to {$until->format('Y-m-d')}. Cart has: {$totalInCart}"
);
} else {
return false;
}
}
} else {
// Without dates, check general pool availability
$available = $product->getPoolMaxQuantity();
$totalInCart = $items->filter(
fn($i) =>
$i->purchasable_id === $product->id &&
$i->purchasable_type === get_class($product)
)->sum('quantity');
if ($available !== PHP_INT_MAX && $totalInCart > $available) {
if ($throws) {
2025-12-19 13:26:57 +00:00
throw new NotEnoughStockException(
"Pool product '{$product->name}' has only {$available} items available. Cart has: {$totalInCart}"
);
} else {
return false;
}
}
}
} elseif ($product->isBooking() && $product->manage_stock) {
// For booking products with managed stock
if ($from && $until) {
if (!$product->isAvailableForBooking($from, $until, $item->quantity)) {
if ($throws) {
2025-12-19 13:26:57 +00:00
throw new NotEnoughStockException(
"Booking product '{$product->name}' is not available for the period " .
"{$from->format('Y-m-d')} to {$until->format('Y-m-d')}. Requested: {$item->quantity}"
);
} else {
return false;
}
}
}
} elseif ($product->manage_stock) {
// For regular products with managed stock
$available = $product->getAvailableStock();
if ($item->quantity > $available) {
if ($throws) {
2025-12-19 13:26:57 +00:00
throw new NotEnoughStockException(
"Product '{$product->name}' has only {$available} items in stock. Requested: {$item->quantity}"
);
} else {
return false;
}
}
}
}
2025-12-18 14:33:47 +00:00
return true;
}
2025-12-26 07:42:59 +00:00
/**
* 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 the `product_id` column when present.
2025-12-26 07:42:59 +00:00
* - 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 () {
2025-12-18 11:21:29 +00:00
// Lock the cart to prevent concurrent checkouts
$this->lockForUpdate();
2025-12-18 11:21:29 +00:00
// Validate cart before proceeding
$this->validateForCheckout();
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-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-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)) {
2025-12-26 07:42:59 +00:00
$from = Carbon::parse($from);
2025-12-18 11:21:29 +00:00
}
if ($until && is_string($until)) {
2025-12-26 07:42:59 +00:00
$until = Carbon::parse($until);
2025-12-18 11:21:29 +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-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 {
// Check if we have pre-allocated single items from product_id column
$allocatedSingleId = $item->product_id;
2025-12-20 10:22:04 +00:00
if ($allocatedSingleId) {
// Use the pre-allocated single item from product_id
$singleItem = $item->product;
2025-12-20 10:22:04 +00:00
if (!$singleItem) {
throw new \Exception("Allocated single item not found: {$allocatedSingleId}");
}
// Claim stock for this specific item
$singleItem->claimStock($quantity, $this, $from, $until, "Checkout from cart {$this->id}");
$claimedItems = [$singleItem];
} else {
// No pre-allocation, use standard pool claiming logic
$claimedItems = $product->claimPoolStock(
$quantity,
$this,
$from,
$until,
"Checkout from cart {$this->id}"
);
}
2025-12-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-11-29 11:05:02 +00:00
2025-12-18 11:21:29 +00:00
// Validate booking products have required dates
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-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-12-29 08:59:02 +00:00
'status' => CartStatus::CONVERTED,
2025-12-18 11:21:29 +00:00
]);
2025-11-29 11:05:02 +00:00
2025-12-29 08:59:02 +00:00
// Create an Order from this converted cart
$order = Order::createFromCart($this);
2025-12-18 11:21:29 +00:00
return $this;
});
2025-11-29 11:05:02 +00:00
}
/**
* 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)
2025-12-17 17:33:34 +00:00
* - 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.)
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
* @throws \Exception
*/
2025-12-18 14:33:47 +00:00
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]);
}
});
2025-12-17 11:26:26 +00:00
$lineItems = [];
2025-12-17 17:33:34 +00:00
foreach ($this->items as $item) {
$product = $item->purchasable;
2025-12-17 17:33:34 +00:00
// Get product name (use short_description if available, otherwise name)
2025-12-29 07:14:58 +00:00
$productName = $product->name ?? 'Product [' . $product->id . ']';
$description = $product->short_description ?? null;
2025-12-17 17:33:34 +00:00
// Build description with booking dates if available
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-29 07:14:58 +00:00
$description .= " from {$fromFormatted} to {$untilFormatted}";
}
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,
2025-12-29 07:14:58 +00:00
...($description ? ['description' => $description] : []),
2025-12-17 17:33:34 +00:00
],
'unit_amount' => $unitAmountCents,
],
'quantity' => $item->quantity,
];
$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;
// Prepare session parameters
$sessionParams = [
'payment_method_types' => ['card'],
2026-01-25 09:40:17 +00:00
'currency' => strtoupper($this->currency),
'line_items' => $lineItems,
'mode' => 'payment',
2025-12-18 14:33:47 +00:00
'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;
}
}
2025-12-18 11:21:29 +00:00
/**
* Get the checkout session link for this cart.
*
* This method returns:
* - string: The checkout session URL if a session exists and is valid
* - null: If no session exists or Stripe is not enabled
* - false: If an error occurred while retrieving the session
*
* @return string|null|false
*/
2025-12-18 14:33:47 +00:00
public function checkoutSessionLink(array $option = [], ?string $url = null): string|null|false
2025-12-18 11:21:29 +00:00
{
2025-12-20 11:19:34 +00:00
// Validate cart - throw exceptions if validation fails
// This ensures users know what's wrong instead of silently returning null
$this->validateForCheckout();
2025-12-18 11:21:29 +00:00
2025-12-18 14:33:47 +00:00
$checkoutSession = $this->checkoutSession($option, $url);
2025-12-18 11:21:29 +00:00
2025-12-18 14:33:47 +00:00
if ($checkoutSession) {
if (
isset($checkoutSession->url)
&& !empty($checkoutSession->url)
) {
return $checkoutSession->url;
2025-12-18 11:21:29 +00:00
}
return false;
}
2025-12-18 14:33:47 +00:00
return null;
2025-12-18 11:21:29 +00:00
}
2025-11-21 10:49:41 +00:00
}