RI cartitems, A exceptions, tests
This commit is contained in:
parent
2f0d0757ee
commit
1b2559b824
|
|
@ -260,6 +260,8 @@ return new class extends Migration
|
|||
$table->timestamp('last_activity_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('converted_at')->nullable();
|
||||
$table->timestamp('from_date')->nullable(); // Default start date for booking items
|
||||
$table->timestamp('until_date')->nullable(); // Default end date for booking items
|
||||
$table->json('meta')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
|
@ -276,6 +278,7 @@ return new class extends Migration
|
|||
$table->uuid('cart_id');
|
||||
$table->uuidMorphs('purchasable');
|
||||
$table->foreignUuid('purchase_id')->nullable()->constrained(config('shop.tables.product_purchases', 'product_purchases'))->nullOnDelete();
|
||||
$table->foreignUuid('price_id')->nullable()->constrained(config('shop.tables.product_prices', 'product_prices'))->nullOnDelete();
|
||||
$table->integer('quantity')->default(1);
|
||||
$table->decimal('price', 10, 2)->default(0);
|
||||
$table->decimal('regular_price', 10, 2)->nullable();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class InvalidDateRangeException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message = "The 'from' date must be before the 'until' date.",
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class NotEnoughAvailableInTimespanException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $productName,
|
||||
public readonly int $requested,
|
||||
public readonly int $available,
|
||||
public readonly \DateTimeInterface $from,
|
||||
public readonly \DateTimeInterface $until,
|
||||
string $message = '',
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
if (empty($message)) {
|
||||
$message = "Not enough '{$productName}' available in the requested timespan. Requested: {$requested}, Available: {$available}.";
|
||||
}
|
||||
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ namespace Blax\Shop\Models;
|
|||
use Blax\Shop\Contracts\Cartable;
|
||||
use Blax\Shop\Enums\CartStatus;
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
use Blax\Shop\Exceptions\InvalidDateRangeException;
|
||||
use Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException;
|
||||
use Blax\Shop\Services\CartService;
|
||||
use Blax\Workkit\Traits\HasExpiration;
|
||||
use Carbon\Carbon;
|
||||
|
|
@ -28,6 +30,8 @@ class Cart extends Model
|
|||
'expires_at',
|
||||
'converted_at',
|
||||
'meta',
|
||||
'from_date',
|
||||
'until_date',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
@ -36,6 +40,13 @@ class Cart extends Model
|
|||
'converted_at' => 'datetime',
|
||||
'last_activity_at' => 'datetime',
|
||||
'meta' => 'object',
|
||||
'from_date' => 'datetime',
|
||||
'until_date' => 'datetime',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'is_full_booking',
|
||||
'is_ready_to_checkout',
|
||||
];
|
||||
|
||||
public function __construct(array $attributes = [])
|
||||
|
|
@ -75,6 +86,61 @@ class Cart extends Model
|
|||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of booking items in the cart
|
||||
*/
|
||||
public function bookingItems(): int
|
||||
{
|
||||
return $this->items->filter(fn($item) => $item->is_booking)->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get array of stripe_price_id from each cart item's price.
|
||||
* Returns array with nulls for items without stripe_price_id.
|
||||
*
|
||||
* @return array<string|null>
|
||||
*/
|
||||
public function stripePriceIds(): array
|
||||
{
|
||||
return $this->items->map(function ($item) {
|
||||
if (!$item->price_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the relationship method, not property access
|
||||
$price = $item->price()->first();
|
||||
return $price ? $price->stripe_price_id : null;
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cart is ready for checkout.
|
||||
*
|
||||
* Returns true if all cart items are ready for checkout.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getIsReadyToCheckoutAttribute(): bool
|
||||
{
|
||||
if ($this->items->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->items->every(fn($item) => $item->is_ready_to_checkout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cart items that require adjustments before checkout.
|
||||
*
|
||||
|
|
@ -115,6 +181,155 @@ class Cart extends Model
|
|||
return $this->getItemsRequiringAdjustments()->isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default date range for the cart.
|
||||
* Items without specific dates will use these as fallback.
|
||||
*
|
||||
* @param \DateTimeInterface $from Start date
|
||||
* @param \DateTimeInterface $until End date
|
||||
* @param bool $validateAvailability Whether to validate product availability for the timespan
|
||||
* @return $this
|
||||
* @throws InvalidDateRangeException
|
||||
* @throws NotEnoughAvailableInTimespanException
|
||||
*/
|
||||
public function setDates(\DateTimeInterface $from, \DateTimeInterface $until, bool $validateAvailability = true): self
|
||||
{
|
||||
if ($from >= $until) {
|
||||
throw new InvalidDateRangeException();
|
||||
}
|
||||
|
||||
if ($validateAvailability) {
|
||||
$this->validateDateAvailability($from, $until);
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'from_date' => $from,
|
||||
'until_date' => $until,
|
||||
]);
|
||||
|
||||
return $this->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the 'from' date for the cart.
|
||||
*
|
||||
* @param \DateTimeInterface $from Start date
|
||||
* @param bool $validateAvailability Whether to validate product availability for the timespan
|
||||
* @return $this
|
||||
* @throws InvalidDateRangeException
|
||||
* @throws NotEnoughAvailableInTimespanException
|
||||
*/
|
||||
public function setFromDate(\DateTimeInterface $from, bool $validateAvailability = true): self
|
||||
{
|
||||
if ($this->until_date && $from >= $this->until_date) {
|
||||
throw new InvalidDateRangeException();
|
||||
}
|
||||
|
||||
if ($validateAvailability && $this->until_date) {
|
||||
$this->validateDateAvailability($from, $this->until_date);
|
||||
}
|
||||
|
||||
$this->update(['from_date' => $from]);
|
||||
|
||||
return $this->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the 'until' date for the cart.
|
||||
*
|
||||
* @param \DateTimeInterface $until End date
|
||||
* @param bool $validateAvailability Whether to validate product availability for the timespan
|
||||
* @return $this
|
||||
* @throws InvalidDateRangeException
|
||||
* @throws NotEnoughAvailableInTimespanException
|
||||
*/
|
||||
public function setUntilDate(\DateTimeInterface $until, bool $validateAvailability = true): self
|
||||
{
|
||||
if ($this->from_date && $this->from_date >= $until) {
|
||||
throw new InvalidDateRangeException();
|
||||
}
|
||||
|
||||
if ($validateAvailability && $this->from_date) {
|
||||
$this->validateDateAvailability($this->from_date, $until);
|
||||
}
|
||||
|
||||
$this->update(['until_date' => $until]);
|
||||
|
||||
return $this->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply cart dates to all items that don't have their own dates set.
|
||||
*
|
||||
* @param bool $validateAvailability Whether to validate product availability for the timespan
|
||||
* @return $this
|
||||
* @throws NotEnoughAvailableInTimespanException
|
||||
*/
|
||||
public function applyDatesToItems(bool $validateAvailability = true): self
|
||||
{
|
||||
if (!$this->from_date || !$this->until_date) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
// Only apply to items without dates that are booking products
|
||||
if ($item->is_booking && (!$item->from || !$item->until)) {
|
||||
if ($validateAvailability) {
|
||||
$product = $item->purchasable;
|
||||
if ($product && !$product->isAvailableForBooking($this->from_date, $this->until_date, $item->quantity)) {
|
||||
throw new NotEnoughAvailableInTimespanException(
|
||||
productName: $product->name ?? 'Product',
|
||||
requested: $item->quantity,
|
||||
available: 0, // Could calculate actual available amount
|
||||
from: $this->from_date,
|
||||
until: $this->until_date
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$item->updateDates($this->from_date, $this->until_date);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that all booking items in the cart are available for the given timespan.
|
||||
*
|
||||
* @param \DateTimeInterface $from Start date
|
||||
* @param \DateTimeInterface $until End date
|
||||
* @return void
|
||||
* @throws NotEnoughAvailableInTimespanException
|
||||
*/
|
||||
protected function validateDateAvailability(\DateTimeInterface $from, \DateTimeInterface $until): void
|
||||
{
|
||||
foreach ($this->items as $item) {
|
||||
if (!$item->is_booking) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$product = $item->purchasable;
|
||||
if (!$product) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use item's specific dates if set, otherwise use the dates being validated
|
||||
$checkFrom = $item->from ?? $from;
|
||||
$checkUntil = $item->until ?? $until;
|
||||
|
||||
if (!$product->isAvailableForBooking($checkFrom, $checkUntil, $item->quantity)) {
|
||||
throw new NotEnoughAvailableInTimespanException(
|
||||
productName: $product->name ?? 'Product',
|
||||
requested: $item->quantity,
|
||||
available: 0, // Could calculate actual available amount
|
||||
from: $checkFrom,
|
||||
until: $checkUntil
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getUnpaidAmount(): float
|
||||
{
|
||||
$paidAmount = $this->purchases()
|
||||
|
|
@ -252,7 +467,7 @@ class Cart extends Model
|
|||
// Validate pricing before adding to cart
|
||||
$cartable->validatePricing(throwExceptions: true);
|
||||
|
||||
// Validate dates if both are provided (optional for cart, required at checkout)
|
||||
// Validate dates if both are provided
|
||||
if ($from && $until) {
|
||||
// Validate from is before until
|
||||
if ($from >= $until) {
|
||||
|
|
@ -269,7 +484,7 @@ class Cart extends Model
|
|||
// Check pool product availability if dates are provided
|
||||
if ($cartable->isPool()) {
|
||||
$maxQuantity = $cartable->getPoolMaxQuantity($from, $until);
|
||||
// Only validate if pool has limited availability
|
||||
// Only validate if pool has limited availability AND quantity exceeds it
|
||||
if ($maxQuantity !== PHP_INT_MAX && $quantity > $maxQuantity) {
|
||||
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
|
||||
"Pool product '{$cartable->name}' has only {$maxQuantity} items available for the requested period ({$from->format('Y-m-d')} to {$until->format('Y-m-d')}). Requested: {$quantity}"
|
||||
|
|
@ -417,10 +632,22 @@ class Cart extends Model
|
|||
return $existingItem->fresh();
|
||||
}
|
||||
|
||||
// Determine price_id for the cart item
|
||||
$priceId = null;
|
||||
if ($cartable instanceof Product) {
|
||||
// Get the default price for the product
|
||||
$defaultPrice = $cartable->defaultPrice()->first();
|
||||
$priceId = $defaultPrice?->id;
|
||||
} elseif ($cartable instanceof \Blax\Shop\Models\ProductPrice) {
|
||||
// If adding a ProductPrice directly, use its ID
|
||||
$priceId = $cartable->id;
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
|
@ -639,30 +866,36 @@ class Cart extends Model
|
|||
// Validate cart before proceeding (doesn't convert it)
|
||||
$this->validateForCheckout();
|
||||
|
||||
// Get all stripe price IDs and validate they exist
|
||||
$stripePriceIds = $this->stripePriceIds();
|
||||
|
||||
// Check if any stripe_price_id is null
|
||||
$nullPriceIndexes = [];
|
||||
foreach ($stripePriceIds as $index => $priceId) {
|
||||
if ($priceId === null) {
|
||||
$nullPriceIndexes[] = $index;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($nullPriceIndexes)) {
|
||||
// Get item names for better error message
|
||||
$itemNames = [];
|
||||
foreach ($nullPriceIndexes as $index) {
|
||||
$item = $this->items[$index];
|
||||
$itemNames[] = $item->purchasable->name ?? "Item {$index}";
|
||||
}
|
||||
throw new \Exception(
|
||||
"Cannot create checkout session: The following items have no Stripe price ID: " .
|
||||
implode(', ', $itemNames)
|
||||
);
|
||||
}
|
||||
|
||||
$syncService = new \Blax\Shop\Services\StripeSyncService();
|
||||
$lineItems = [];
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
$purchasable = $item->purchasable;
|
||||
|
||||
// Get the price model
|
||||
if ($purchasable instanceof Product) {
|
||||
$price = $purchasable->defaultPrice()->first();
|
||||
$product = $purchasable;
|
||||
} elseif ($purchasable instanceof \Blax\Shop\Models\ProductPrice) {
|
||||
$price = $purchasable;
|
||||
$product = $purchasable->purchasable;
|
||||
} else {
|
||||
throw new \Exception("Item has no valid price");
|
||||
}
|
||||
|
||||
if (!$price) {
|
||||
$name = $purchasable->name ?? 'Unknown item';
|
||||
throw new \Exception("Item '{$name}' has no default price");
|
||||
}
|
||||
|
||||
// Sync product and price to Stripe
|
||||
$stripePriceId = $syncService->syncPrice($price, $product);
|
||||
foreach ($this->items as $index => $item) {
|
||||
// Use the pre-fetched stripe price ID
|
||||
$stripePriceId = $stripePriceIds[$index];
|
||||
|
||||
// Build line item with description including booking dates if applicable
|
||||
$lineItem = [
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Blax\Shop\Models;
|
||||
|
||||
use Blax\Shop\Exceptions\InvalidDateRangeException;
|
||||
use Blax\Workkit\Traits\HasMeta;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
@ -15,6 +16,7 @@ class CartItem extends Model
|
|||
'cart_id',
|
||||
'purchasable_id',
|
||||
'purchasable_type',
|
||||
'price_id',
|
||||
'quantity',
|
||||
'price',
|
||||
'regular_price',
|
||||
|
|
@ -37,6 +39,11 @@ class CartItem extends Model
|
|||
'until' => 'datetime',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'is_booking',
|
||||
'is_ready_to_checkout',
|
||||
];
|
||||
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
parent::__construct($attributes);
|
||||
|
|
@ -66,6 +73,11 @@ class CartItem extends Model
|
|||
return $this->belongsTo(config('shop.models.cart'), 'cart_id');
|
||||
}
|
||||
|
||||
public function price(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(config('shop.models.product_price', ProductPrice::class), 'price_id');
|
||||
}
|
||||
|
||||
public function purchasable()
|
||||
{
|
||||
return $this->morphTo('purchasable');
|
||||
|
|
@ -104,6 +116,153 @@ class CartItem extends Model
|
|||
return $query->where('product_id', $productId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this cart item is for a booking product
|
||||
*/
|
||||
public function getIsBookingAttribute(): bool
|
||||
{
|
||||
if (!$this->price_id) {
|
||||
// Fallback: check purchasable directly if no price_id
|
||||
if ($this->purchasable_type === config('shop.models.product', Product::class)) {
|
||||
$product = $this->purchasable;
|
||||
return $product && $product->isBooking();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use the relationship method, not property access
|
||||
$price = $this->price()->first();
|
||||
if (!$price) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$product = $price->purchasable;
|
||||
if (!$product || !($product instanceof Product)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $product->isBooking();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this cart item is ready for checkout.
|
||||
* Uses effective dates (item's own dates or cart's dates as fallback).
|
||||
*
|
||||
* Returns true if:
|
||||
* - For booking products: has valid dates and stock is available
|
||||
* - For pool products with booking items: has valid dates and stock is available
|
||||
* - For other products: stock is available
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getIsReadyToCheckoutAttribute(): bool
|
||||
{
|
||||
// Only check if purchasable is a Product
|
||||
if ($this->purchasable_type !== config('shop.models.product', Product::class)) {
|
||||
return true; // Non-product items are always ready
|
||||
}
|
||||
|
||||
$product = $this->purchasable;
|
||||
|
||||
if (!$product) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if dates are required (for booking products or pools with booking items)
|
||||
$requiresDates = $product->isBooking() ||
|
||||
($product->isPool() && $product->hasBookingSingleItems());
|
||||
|
||||
if ($requiresDates) {
|
||||
// Get effective dates (item-specific or cart fallback)
|
||||
$effectiveFrom = $this->getEffectiveFromDate();
|
||||
$effectiveUntil = $this->getEffectiveUntilDate();
|
||||
|
||||
// Must have both dates (either from item or cart)
|
||||
if (is_null($effectiveFrom) || is_null($effectiveUntil)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Dates must be valid (from < until)
|
||||
if ($effectiveFrom >= $effectiveUntil) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check stock availability for the booking period
|
||||
if ($product->isBooking()) {
|
||||
if (!$product->isAvailableForBooking($effectiveFrom, $effectiveUntil, $this->quantity)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check pool availability with dates
|
||||
if ($product->isPool()) {
|
||||
$available = $product->getPoolMaxQuantity($effectiveFrom, $effectiveUntil);
|
||||
|
||||
// Get current quantity in cart for this product (excluding this item)
|
||||
$cartQuantity = 0;
|
||||
if ($this->cart) {
|
||||
$cartQuantity = $this->cart->items()
|
||||
->where('purchasable_id', $product->getKey())
|
||||
->where('purchasable_type', get_class($product))
|
||||
->where('id', '!=', $this->id)
|
||||
->sum('quantity');
|
||||
}
|
||||
|
||||
if ($available !== PHP_INT_MAX && ($cartQuantity + $this->quantity) > $available) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For non-booking products, just check stock availability
|
||||
if ($product->isPool()) {
|
||||
$available = $product->getPoolMaxQuantity();
|
||||
|
||||
// Get current quantity in cart for this product (excluding this item)
|
||||
$cartQuantity = 0;
|
||||
if ($this->cart) {
|
||||
$cartQuantity = $this->cart->items()
|
||||
->where('purchasable_id', $product->getKey())
|
||||
->where('purchasable_type', get_class($product))
|
||||
->where('id', '!=', $this->id)
|
||||
->sum('quantity');
|
||||
}
|
||||
|
||||
if ($available !== PHP_INT_MAX && ($cartQuantity + $this->quantity) > $available) {
|
||||
return false;
|
||||
}
|
||||
} elseif ($product->manage_stock) {
|
||||
// Check regular stock - sum all stocks for this product
|
||||
$totalStock = $product->stocks()->sum('quantity');
|
||||
|
||||
// If no stock records exist and manage_stock is true, product is not ready
|
||||
// (stock records must be created explicitly)
|
||||
if ($totalStock === 0 && $product->stocks()->count() > 0) {
|
||||
// Has stock records but quantity is 0
|
||||
return false;
|
||||
}
|
||||
|
||||
// If stock records exist, check cart quantity against stock
|
||||
if ($product->stocks()->count() > 0) {
|
||||
// Get current quantity in cart for this product (including ALL items of this product)
|
||||
$cartQuantity = 0;
|
||||
if ($this->cart) {
|
||||
$cartQuantity = $this->cart->items()
|
||||
->where('purchasable_id', $product->getKey())
|
||||
->where('purchasable_type', get_class($product))
|
||||
->sum('quantity');
|
||||
}
|
||||
|
||||
if ($cartQuantity > $totalStock) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// If no stock records exist, assume product is available (legacy behavior)
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get required adjustments for this cart item before checkout.
|
||||
*
|
||||
|
|
@ -169,18 +328,63 @@ class CartItem extends Model
|
|||
return $adjustments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective 'from' date for this cart item.
|
||||
* Returns the item's specific date if set, otherwise falls back to the cart's from_date.
|
||||
*
|
||||
* @return \Carbon\Carbon|null
|
||||
*/
|
||||
public function getEffectiveFromDate(): ?\Carbon\Carbon
|
||||
{
|
||||
if ($this->from) {
|
||||
return $this->from;
|
||||
}
|
||||
|
||||
return $this->cart?->from_date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective 'until' date for this cart item.
|
||||
* Returns the item's specific date if set, otherwise falls back to the cart's until_date.
|
||||
*
|
||||
* @return \Carbon\Carbon|null
|
||||
*/
|
||||
public function getEffectiveUntilDate(): ?\Carbon\Carbon
|
||||
{
|
||||
if ($this->until) {
|
||||
return $this->until;
|
||||
}
|
||||
|
||||
return $this->cart?->until_date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this item has effective dates (either its own or from cart).
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasEffectiveDates(): bool
|
||||
{
|
||||
return $this->getEffectiveFromDate() !== null && $this->getEffectiveUntilDate() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the booking dates for this cart item.
|
||||
* Automatically recalculates price based on the new date range.
|
||||
*
|
||||
* @param \DateTimeInterface $from Start date
|
||||
* @param \DateTimeInterface $until End date
|
||||
* NOTE: This method allows setting any dates, even if they're not available.
|
||||
* Use the is_ready_to_checkout attribute to check if the dates are valid.
|
||||
*
|
||||
* @param \DateTimeInterface|null $from Start date
|
||||
* @param \DateTimeInterface|null $until End date
|
||||
* @return $this
|
||||
* @throws \Exception If dates are invalid
|
||||
*/
|
||||
public function updateDates(\DateTimeInterface $from, \DateTimeInterface $until): self
|
||||
{
|
||||
if ($from >= $until) {
|
||||
public function updateDates(
|
||||
\DateTimeInterface|null $from = null,
|
||||
\DateTimeInterface|null $until = null
|
||||
): self {
|
||||
if ($from >= $until && $until) {
|
||||
throw new \Exception("The 'from' date must be before the 'until' date.");
|
||||
}
|
||||
|
||||
|
|
@ -209,6 +413,7 @@ class CartItem extends Model
|
|||
'subtotal' => $pricePerUnit * $this->quantity,
|
||||
]);
|
||||
|
||||
// Note: is_ready_to_checkout will automatically reflect if these dates are available
|
||||
return $this->fresh();
|
||||
}
|
||||
|
||||
|
|
@ -217,21 +422,26 @@ class CartItem extends Model
|
|||
*
|
||||
* @param \DateTimeInterface $from Start date
|
||||
* @return $this
|
||||
* @throws InvalidDateRangeException
|
||||
*/
|
||||
public function setFromDate(\DateTimeInterface $from): self
|
||||
{
|
||||
if ($this->until && $from >= $this->until) {
|
||||
throw new \Exception("The 'from' date must be before the 'until' date.");
|
||||
throw new InvalidDateRangeException();
|
||||
}
|
||||
|
||||
// Refresh to get current state before checking
|
||||
$this->refresh();
|
||||
|
||||
$this->update(['from' => $from]);
|
||||
$this->refresh();
|
||||
|
||||
// If both dates are now set, recalculate pricing
|
||||
if ($this->until) {
|
||||
return $this->updateDates($from, $this->until);
|
||||
return $this->updateDates($this->from, $this->until);
|
||||
}
|
||||
|
||||
return $this->fresh();
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -239,20 +449,25 @@ class CartItem extends Model
|
|||
*
|
||||
* @param \DateTimeInterface $until End date
|
||||
* @return $this
|
||||
* @throws InvalidDateRangeException
|
||||
*/
|
||||
public function setUntilDate(\DateTimeInterface $until): self
|
||||
{
|
||||
if ($this->from && $this->from >= $until) {
|
||||
throw new \Exception("The 'until' date must be after the 'from' date.");
|
||||
throw new InvalidDateRangeException();
|
||||
}
|
||||
|
||||
// Refresh to get current state before checking
|
||||
$this->refresh();
|
||||
|
||||
$this->update(['until' => $until]);
|
||||
$this->refresh();
|
||||
|
||||
// If both dates are now set, recalculate pricing
|
||||
if ($this->from) {
|
||||
return $this->updateDates($this->from, $until);
|
||||
return $this->updateDates($this->from, $this->until);
|
||||
}
|
||||
|
||||
return $this->fresh();
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,434 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Tests\Feature;
|
||||
|
||||
use Blax\Shop\Exceptions\InvalidDateRangeException;
|
||||
use Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException;
|
||||
use Blax\Shop\Models\Cart;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Models\ProductPrice;
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
use Blax\Shop\Enums\PriceType;
|
||||
use Blax\Shop\Tests\TestCase;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class CartDateManagementTest extends TestCase
|
||||
{
|
||||
/** @test */
|
||||
public function it_can_set_cart_dates()
|
||||
{
|
||||
$cart = Cart::factory()->create();
|
||||
$from = Carbon::now()->addDays(1);
|
||||
$until = Carbon::now()->addDays(3);
|
||||
|
||||
$cart->setDates($from, $until, validateAvailability: false);
|
||||
|
||||
$cart->refresh();
|
||||
$this->assertEquals($from->toDateTimeString(), $cart->from_date->toDateTimeString());
|
||||
$this->assertEquals($until->toDateTimeString(), $cart->until_date->toDateTimeString());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_throws_exception_when_from_date_is_after_until_date()
|
||||
{
|
||||
$cart = Cart::factory()->create();
|
||||
$from = Carbon::now()->addDays(3);
|
||||
$until = Carbon::now()->addDays(1);
|
||||
|
||||
$this->expectException(InvalidDateRangeException::class);
|
||||
$cart->setDates($from, $until, validateAvailability: false);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_set_from_date_individually()
|
||||
{
|
||||
$cart = Cart::factory()->create();
|
||||
$from = Carbon::now()->addDays(1);
|
||||
|
||||
$cart->setFromDate($from, validateAvailability: false);
|
||||
|
||||
$cart->refresh();
|
||||
$this->assertEquals($from->toDateTimeString(), $cart->from_date->toDateTimeString());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_set_until_date_individually()
|
||||
{
|
||||
$cart = Cart::factory()->create();
|
||||
$until = Carbon::now()->addDays(3);
|
||||
|
||||
$cart->setUntilDate($until, validateAvailability: false);
|
||||
|
||||
$cart->refresh();
|
||||
$this->assertEquals($until->toDateTimeString(), $cart->until_date->toDateTimeString());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_throws_exception_when_setting_from_date_after_existing_until_date()
|
||||
{
|
||||
$cart = Cart::factory()->create([
|
||||
'until_date' => Carbon::now()->addDays(2),
|
||||
]);
|
||||
|
||||
$this->expectException(InvalidDateRangeException::class);
|
||||
$cart->setFromDate(Carbon::now()->addDays(3), validateAvailability: false);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_throws_exception_when_setting_until_date_before_existing_from_date()
|
||||
{
|
||||
$cart = Cart::factory()->create([
|
||||
'from_date' => Carbon::now()->addDays(3),
|
||||
]);
|
||||
|
||||
$this->expectException(InvalidDateRangeException::class);
|
||||
$cart->setUntilDate(Carbon::now()->addDays(2), validateAvailability: false);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_item_uses_own_dates_when_set()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'type' => PriceType::RECURRING,
|
||||
'is_default' => true,
|
||||
|
||||
]);
|
||||
|
||||
$cart = Cart::factory()->create([
|
||||
'from_date' => Carbon::now()->addDays(1),
|
||||
'until_date' => Carbon::now()->addDays(3),
|
||||
]);
|
||||
|
||||
$itemFromDate = Carbon::now()->addDays(5);
|
||||
$itemUntilDate = Carbon::now()->addDays(7);
|
||||
|
||||
$item = $cart->addToCart($product, 1);
|
||||
$item->updateDates($itemFromDate, $itemUntilDate);
|
||||
|
||||
$this->assertEquals($itemFromDate->toDateString(), $item->getEffectiveFromDate()->toDateString());
|
||||
$this->assertEquals($itemUntilDate->toDateString(), $item->getEffectiveUntilDate()->toDateString());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_item_falls_back_to_cart_dates_when_no_own_dates()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'type' => PriceType::RECURRING,
|
||||
'is_default' => true,
|
||||
|
||||
]);
|
||||
|
||||
$cartFromDate = Carbon::now()->addDays(1);
|
||||
$cartUntilDate = Carbon::now()->addDays(3);
|
||||
|
||||
$cart = Cart::factory()->create([
|
||||
'from_date' => $cartFromDate,
|
||||
'until_date' => $cartUntilDate,
|
||||
]);
|
||||
|
||||
$item = $cart->addToCart($product, 1);
|
||||
|
||||
$this->assertEquals($cartFromDate->toDateString(), $item->getEffectiveFromDate()->toDateString());
|
||||
$this->assertEquals($cartUntilDate->toDateString(), $item->getEffectiveUntilDate()->toDateString());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_item_returns_null_when_no_dates_available()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'type' => PriceType::RECURRING,
|
||||
'is_default' => true,
|
||||
|
||||
]);
|
||||
|
||||
$cart = Cart::factory()->create();
|
||||
$item = $cart->addToCart($product, 1);
|
||||
|
||||
$this->assertNull($item->getEffectiveFromDate());
|
||||
$this->assertNull($item->getEffectiveUntilDate());
|
||||
$this->assertFalse($item->hasEffectiveDates());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_item_has_effective_dates_returns_true_when_dates_are_set()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'type' => PriceType::RECURRING,
|
||||
'is_default' => true,
|
||||
|
||||
]);
|
||||
|
||||
$cart = Cart::factory()->create([
|
||||
'from_date' => Carbon::now()->addDays(1),
|
||||
'until_date' => Carbon::now()->addDays(3),
|
||||
]);
|
||||
|
||||
$item = $cart->addToCart($product, 1);
|
||||
|
||||
$this->assertTrue($item->hasEffectiveDates());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function apply_dates_to_items_sets_dates_on_items_without_dates()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'type' => PriceType::RECURRING,
|
||||
'is_default' => true,
|
||||
|
||||
]);
|
||||
|
||||
$cart = Cart::factory()->create();
|
||||
$item = $cart->addToCart($product, 1);
|
||||
|
||||
$this->assertNull($item->from);
|
||||
$this->assertNull($item->until);
|
||||
|
||||
$fromDate = Carbon::now()->addDays(1);
|
||||
$untilDate = Carbon::now()->addDays(3);
|
||||
|
||||
$cart->setDates($fromDate, $untilDate, validateAvailability: false);
|
||||
$cart->applyDatesToItems(validateAvailability: false);
|
||||
|
||||
$item->refresh();
|
||||
$this->assertNotNull($item->from);
|
||||
$this->assertNotNull($item->until);
|
||||
$this->assertEquals($fromDate->toDateString(), $item->from->toDateString());
|
||||
$this->assertEquals($untilDate->toDateString(), $item->until->toDateString());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function apply_dates_to_items_does_not_override_existing_item_dates()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'type' => PriceType::RECURRING,
|
||||
'is_default' => true,
|
||||
|
||||
]);
|
||||
|
||||
$cart = Cart::factory()->create();
|
||||
$item = $cart->addToCart($product, 1);
|
||||
|
||||
$itemFromDate = Carbon::now()->addDays(5);
|
||||
$itemUntilDate = Carbon::now()->addDays(7);
|
||||
$item->updateDates($itemFromDate, $itemUntilDate);
|
||||
|
||||
$cartFromDate = Carbon::now()->addDays(1);
|
||||
$cartUntilDate = Carbon::now()->addDays(3);
|
||||
|
||||
$cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false);
|
||||
$cart->applyDatesToItems(validateAvailability: false);
|
||||
|
||||
$item->refresh();
|
||||
// Item dates should remain unchanged
|
||||
$this->assertEquals($itemFromDate->toDateString(), $item->from->toDateString());
|
||||
$this->assertEquals($itemUntilDate->toDateString(), $item->until->toDateString());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function is_ready_to_checkout_uses_cart_fallback_dates()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'type' => PriceType::RECURRING,
|
||||
'is_default' => true,
|
||||
|
||||
]);
|
||||
|
||||
$cart = Cart::factory()->create([
|
||||
'from_date' => Carbon::now()->addDays(1),
|
||||
'until_date' => Carbon::now()->addDays(3),
|
||||
]);
|
||||
|
||||
$item = $cart->addToCart($product, 1);
|
||||
|
||||
// Item should be ready because it uses cart dates
|
||||
$this->assertTrue($item->is_ready_to_checkout);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_item_set_from_date_throws_invalid_date_range_exception()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'type' => PriceType::RECURRING,
|
||||
'is_default' => true,
|
||||
|
||||
]);
|
||||
|
||||
$cart = Cart::factory()->create();
|
||||
$item = $cart->addToCart($product, 1);
|
||||
|
||||
$item->setUntilDate(Carbon::now()->addDays(2));
|
||||
|
||||
$this->expectException(InvalidDateRangeException::class);
|
||||
$item->setFromDate(Carbon::now()->addDays(3));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_item_set_until_date_throws_invalid_date_range_exception()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'type' => PriceType::RECURRING,
|
||||
'is_default' => true,
|
||||
|
||||
]);
|
||||
|
||||
$cart = Cart::factory()->create();
|
||||
$item = $cart->addToCart($product, 1);
|
||||
|
||||
$item->setFromDate(Carbon::now()->addDays(3));
|
||||
|
||||
$this->expectException(InvalidDateRangeException::class);
|
||||
$item->setUntilDate(Carbon::now()->addDays(2));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function validate_date_availability_throws_exception_when_product_not_available()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 1,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'type' => PriceType::RECURRING,
|
||||
'is_default' => true,
|
||||
|
||||
]);
|
||||
|
||||
$cart = Cart::factory()->create();
|
||||
$item = $cart->addToCart($product, 1);
|
||||
|
||||
// Set item dates that consume the stock
|
||||
$item->updateDates(Carbon::now()->addDays(1), Carbon::now()->addDays(3));
|
||||
|
||||
// Try to set cart dates that overlap - should throw exception
|
||||
$this->expectException(NotEnoughAvailableInTimespanException::class);
|
||||
$cart->setDates(Carbon::now()->addDays(2), Carbon::now()->addDays(4), validateAvailability: true);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function apply_dates_to_items_throws_exception_when_product_not_available()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 1,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'type' => PriceType::RECURRING,
|
||||
'is_default' => true,
|
||||
|
||||
]);
|
||||
|
||||
$cart = Cart::factory()->create([
|
||||
'from_date' => Carbon::now()->addDays(1),
|
||||
'until_date' => Carbon::now()->addDays(3),
|
||||
]);
|
||||
|
||||
// Add item that would exceed available stock
|
||||
$item = $cart->addToCart($product, 2);
|
||||
|
||||
// Should throw exception because only 1 available but requesting 2
|
||||
$this->expectException(NotEnoughAvailableInTimespanException::class);
|
||||
$cart->applyDatesToItems(validateAvailability: true);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_skip_validation_when_setting_dates()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 0, // No stock available
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'type' => PriceType::RECURRING,
|
||||
'is_default' => true,
|
||||
|
||||
]);
|
||||
|
||||
$cart = Cart::factory()->create();
|
||||
$item = $cart->addToCart($product, 1);
|
||||
|
||||
// Should not throw exception when validation is disabled
|
||||
$cart->setDates(
|
||||
Carbon::now()->addDays(1),
|
||||
Carbon::now()->addDays(3),
|
||||
validateAvailability: false
|
||||
);
|
||||
|
||||
$this->assertNotNull($cart->from_date);
|
||||
$this->assertNotNull($cart->until_date);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,486 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Tests\Feature;
|
||||
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
use Blax\Shop\Models\Cart;
|
||||
use Blax\Shop\Models\CartItem;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Models\ProductPrice;
|
||||
use Blax\Shop\Tests\TestCase;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Workbench\App\Models\User;
|
||||
|
||||
class CartItemAttributesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** @test */
|
||||
public function cart_item_has_is_booking_attribute_for_booking_products()
|
||||
{
|
||||
$bookingProduct = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cartItem = $cart->addToCart($bookingProduct, quantity: 1);
|
||||
|
||||
$this->assertTrue($cartItem->is_booking);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_item_has_is_booking_false_for_regular_products()
|
||||
{
|
||||
$regularProduct = Product::factory()
|
||||
->withPrices(unit_amount: 50.00)
|
||||
->create(['type' => ProductType::SIMPLE]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cartItem = $cart->addToCart($regularProduct, quantity: 1);
|
||||
|
||||
$this->assertFalse($cartItem->is_booking);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_item_is_booking_works_via_price_id()
|
||||
{
|
||||
$bookingProduct = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cartItem = $cart->addToCart($bookingProduct, quantity: 1);
|
||||
|
||||
// Verify price_id was set
|
||||
$this->assertNotNull($cartItem->price_id);
|
||||
|
||||
// Reload and check is_booking still works
|
||||
$reloadedItem = CartItem::find($cartItem->id);
|
||||
$this->assertTrue($reloadedItem->is_booking);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_is_full_booking_is_true_when_all_items_are_bookings()
|
||||
{
|
||||
$booking1 = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$booking2 = Product::factory()
|
||||
->withPrices(unit_amount: 150.00)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cart->addToCart($booking1, quantity: 1);
|
||||
$cart->addToCart($booking2, quantity: 1);
|
||||
|
||||
$this->assertTrue($cart->is_full_booking);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_is_full_booking_is_false_when_mixed_products()
|
||||
{
|
||||
$booking = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$regular = Product::factory()
|
||||
->withPrices(unit_amount: 50.00)
|
||||
->create(['type' => ProductType::SIMPLE]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cart->addToCart($booking, quantity: 1);
|
||||
$cart->addToCart($regular, quantity: 1);
|
||||
|
||||
$this->assertFalse($cart->is_full_booking);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_is_full_booking_is_false_when_empty()
|
||||
{
|
||||
$cart = Cart::create();
|
||||
|
||||
$this->assertFalse($cart->is_full_booking);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_booking_items_returns_correct_count()
|
||||
{
|
||||
$booking1 = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$booking2 = Product::factory()
|
||||
->withPrices(unit_amount: 150.00)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$regular = Product::factory()
|
||||
->withPrices(unit_amount: 50.00)
|
||||
->create(['type' => ProductType::SIMPLE]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cart->addToCart($booking1, quantity: 1);
|
||||
$cart->addToCart($booking2, quantity: 1);
|
||||
$cart->addToCart($regular, quantity: 1);
|
||||
|
||||
$this->assertEquals(2, $cart->bookingItems());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_booking_items_returns_zero_when_no_bookings()
|
||||
{
|
||||
$regular = Product::factory()
|
||||
->withPrices(unit_amount: 50.00)
|
||||
->create(['type' => ProductType::SIMPLE]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cart->addToCart($regular, quantity: 1);
|
||||
|
||||
$this->assertEquals(0, $cart->bookingItems());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function price_id_is_automatically_assigned_when_adding_product_to_cart()
|
||||
{
|
||||
$product = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->create();
|
||||
|
||||
$cart = Cart::create();
|
||||
$cartItem = $cart->addToCart($product, quantity: 1);
|
||||
|
||||
$this->assertNotNull($cartItem->price_id);
|
||||
|
||||
// Access the relationship using the method, not property
|
||||
$this->assertInstanceOf(ProductPrice::class, $cartItem->price()->first());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function price_id_is_assigned_when_adding_product_price_to_cart()
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
$price = ProductPrice::factory()->create([
|
||||
'purchasable_type' => get_class($product),
|
||||
'purchasable_id' => $product->id,
|
||||
'unit_amount' => 100.00,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cartItem = $cart->addToCart($price, quantity: 1);
|
||||
|
||||
$this->assertEquals($price->id, $cartItem->price_id);
|
||||
|
||||
// Access the relationship using the method, not property
|
||||
$this->assertInstanceOf(ProductPrice::class, $cartItem->price()->first());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_stripe_price_ids_returns_array_of_stripe_price_ids()
|
||||
{
|
||||
$product1 = Product::factory()->create();
|
||||
$price1 = ProductPrice::factory()->create([
|
||||
'purchasable_type' => get_class($product1),
|
||||
'purchasable_id' => $product1->id,
|
||||
'stripe_price_id' => 'price_123',
|
||||
'unit_amount' => 100.00,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$product2 = Product::factory()->create();
|
||||
$price2 = ProductPrice::factory()->create([
|
||||
'purchasable_type' => get_class($product2),
|
||||
'purchasable_id' => $product2->id,
|
||||
'stripe_price_id' => 'price_456',
|
||||
'unit_amount' => 200.00,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cart->addToCart($product1, quantity: 1);
|
||||
$cart->addToCart($product2, quantity: 1);
|
||||
|
||||
$stripePriceIds = $cart->stripePriceIds();
|
||||
|
||||
$this->assertCount(2, $stripePriceIds);
|
||||
$this->assertContains('price_123', $stripePriceIds);
|
||||
$this->assertContains('price_456', $stripePriceIds);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_stripe_price_ids_returns_nulls_for_items_without_stripe_price_id()
|
||||
{
|
||||
$product1 = Product::factory()->create();
|
||||
$price1 = ProductPrice::factory()->create([
|
||||
'purchasable_type' => get_class($product1),
|
||||
'purchasable_id' => $product1->id,
|
||||
'stripe_price_id' => 'price_123',
|
||||
'unit_amount' => 100.00,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$product2 = Product::factory()->create();
|
||||
$price2 = ProductPrice::factory()->create([
|
||||
'purchasable_type' => get_class($product2),
|
||||
'purchasable_id' => $product2->id,
|
||||
'stripe_price_id' => null,
|
||||
'unit_amount' => 200.00,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cart->addToCart($product1, quantity: 1);
|
||||
$cart->addToCart($product2, quantity: 1);
|
||||
|
||||
$stripePriceIds = $cart->stripePriceIds();
|
||||
|
||||
$this->assertCount(2, $stripePriceIds);
|
||||
$this->assertEquals('price_123', $stripePriceIds[0]);
|
||||
$this->assertNull($stripePriceIds[1]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_item_is_ready_to_checkout_is_true_for_regular_products()
|
||||
{
|
||||
$product = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->create(['type' => ProductType::SIMPLE]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cartItem = $cart->addToCart($product, quantity: 1);
|
||||
|
||||
$this->assertTrue($cartItem->is_ready_to_checkout);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_item_is_ready_to_checkout_is_false_for_booking_without_dates()
|
||||
{
|
||||
$bookingProduct = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cartItem = $cart->addToCart($bookingProduct, quantity: 1);
|
||||
|
||||
$this->assertFalse($cartItem->is_ready_to_checkout);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_item_is_ready_to_checkout_is_true_for_booking_with_valid_dates()
|
||||
{
|
||||
$bookingProduct = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->withStocks(quantity: 10)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$from = Carbon::now()->addDays(1);
|
||||
$until = Carbon::now()->addDays(3);
|
||||
|
||||
$cartItem = $cart->addToCart($bookingProduct, quantity: 1, from: $from, until: $until);
|
||||
|
||||
$this->assertTrue($cartItem->is_ready_to_checkout);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_item_is_ready_to_checkout_is_false_for_booking_with_invalid_date_range()
|
||||
{
|
||||
$bookingProduct = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->withStocks(quantity: 10)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cartItem = $cart->addToCart($bookingProduct, quantity: 1);
|
||||
|
||||
// Manually set invalid dates (from >= until)
|
||||
$cartItem->update([
|
||||
'from' => Carbon::now()->addDays(3),
|
||||
'until' => Carbon::now()->addDays(1), // until before from
|
||||
]);
|
||||
|
||||
$this->assertFalse($cartItem->fresh()->is_ready_to_checkout);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_is_ready_to_checkout_is_true_when_all_items_are_ready()
|
||||
{
|
||||
$product1 = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->create(['type' => ProductType::SIMPLE]);
|
||||
|
||||
$product2 = Product::factory()
|
||||
->withPrices(unit_amount: 150.00)
|
||||
->create(['type' => ProductType::SIMPLE]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cart->addToCart($product1, quantity: 1);
|
||||
$cart->addToCart($product2, quantity: 1);
|
||||
|
||||
$this->assertTrue($cart->is_ready_to_checkout);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_is_ready_to_checkout_is_false_when_at_least_one_item_not_ready()
|
||||
{
|
||||
$regularProduct = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->create(['type' => ProductType::SIMPLE]);
|
||||
|
||||
$bookingProduct = Product::factory()
|
||||
->withPrices(unit_amount: 150.00)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cart->addToCart($regularProduct, quantity: 1);
|
||||
$cart->addToCart($bookingProduct, quantity: 1); // No dates
|
||||
|
||||
$this->assertFalse($cart->is_ready_to_checkout);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_allows_adding_items_without_dates_that_require_them()
|
||||
{
|
||||
$bookingProduct = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->withStocks(quantity: 10) // Has stock
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$cart = Cart::create();
|
||||
|
||||
// Add without dates - should be allowed
|
||||
$cartItem = $cart->addToCart($bookingProduct, quantity: 1);
|
||||
|
||||
$this->assertInstanceOf(CartItem::class, $cartItem);
|
||||
|
||||
// But is_ready_to_checkout should be false (missing dates)
|
||||
$this->assertFalse($cartItem->is_ready_to_checkout);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function update_dates_allows_setting_any_dates()
|
||||
{
|
||||
$bookingProduct = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->withStocks(quantity: 10) // Has stock
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$cartItem = $cart->addToCart($bookingProduct, quantity: 1);
|
||||
|
||||
$from = Carbon::now()->addDays(1);
|
||||
$until = Carbon::now()->addDays(3);
|
||||
|
||||
// Can set dates anytime
|
||||
$cartItem->updateDates($from, $until);
|
||||
|
||||
$this->assertNotNull($cartItem->from);
|
||||
$this->assertNotNull($cartItem->until);
|
||||
|
||||
// Should be ready to checkout now (has dates and stock)
|
||||
$this->assertTrue($cartItem->fresh()->is_ready_to_checkout);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_calculates_correctly_when_dates_are_adjusted()
|
||||
{
|
||||
$bookingProduct = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->withStocks(quantity: 10)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$from = Carbon::now()->addDays(1);
|
||||
$until = Carbon::now()->addDays(3); // 2 days
|
||||
|
||||
$cartItem = $cart->addToCart($bookingProduct, quantity: 1, from: $from, until: $until);
|
||||
|
||||
// Initial price for 2 days
|
||||
$this->assertEquals(200.00, $cartItem->price);
|
||||
$this->assertEquals(200.00, $cartItem->subtotal);
|
||||
|
||||
// Adjust dates to 5 days
|
||||
$newUntil = Carbon::now()->addDays(6);
|
||||
$cartItem->updateDates($from, $newUntil);
|
||||
|
||||
// Price should be recalculated for 5 days
|
||||
$this->assertEquals(500.00, $cartItem->fresh()->price);
|
||||
$this->assertEquals(500.00, $cartItem->fresh()->subtotal);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function set_from_date_recalculates_pricing_when_both_dates_set()
|
||||
{
|
||||
$bookingProduct = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->withStocks(quantity: 10)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$from = Carbon::now()->addDays(1);
|
||||
$until = Carbon::now()->addDays(4); // 3 days
|
||||
|
||||
$cartItem = $cart->addToCart($bookingProduct, quantity: 1, from: $from, until: $until);
|
||||
|
||||
// Initial price for 3 days
|
||||
$this->assertEquals(300.00, $cartItem->price);
|
||||
|
||||
// Adjust from date to make it span more days (move 1 day earlier)
|
||||
$newFrom = $from->copy()->subDays(1);
|
||||
$cartItem->setFromDate($newFrom);
|
||||
|
||||
// Price should be recalculated for 4 days
|
||||
$this->assertEquals(400.00, $cartItem->fresh()->price);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function set_until_date_recalculates_pricing_when_both_dates_set()
|
||||
{
|
||||
$bookingProduct = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->withStocks(quantity: 10)
|
||||
->create(['type' => ProductType::BOOKING]);
|
||||
|
||||
$cart = Cart::create();
|
||||
$from = Carbon::now()->addDays(1);
|
||||
$until = Carbon::now()->addDays(3); // 2 days
|
||||
|
||||
$cartItem = $cart->addToCart($bookingProduct, quantity: 1, from: $from, until: $until);
|
||||
|
||||
// Initial price for 2 days
|
||||
$this->assertEquals(200.00, $cartItem->price);
|
||||
|
||||
// Adjust until date to make it 4 days
|
||||
$newUntil = Carbon::now()->addDays(5);
|
||||
$cartItem->setUntilDate($newUntil);
|
||||
|
||||
// Price should be recalculated for 4 days
|
||||
$this->assertEquals(400.00, $cartItem->fresh()->price);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function is_ready_to_checkout_checks_stock_for_regular_products_with_stock_management()
|
||||
{
|
||||
$product = Product::factory()
|
||||
->withPrices(unit_amount: 100.00)
|
||||
->withStocks(quantity: 5)
|
||||
->create([
|
||||
'type' => ProductType::SIMPLE,
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
|
||||
$cart = Cart::create();
|
||||
|
||||
// Add 3 items - should be ready
|
||||
$cartItem1 = $cart->addToCart($product, quantity: 3);
|
||||
$this->assertTrue($cartItem1->is_ready_to_checkout);
|
||||
|
||||
// Add 5 more items - now exceeds stock
|
||||
$cartItem2 = $cart->addToCart($product, quantity: 5);
|
||||
|
||||
// Both items should now show as not ready (total exceeds stock)
|
||||
$this->assertFalse($cartItem1->fresh()->is_ready_to_checkout);
|
||||
$this->assertFalse($cartItem2->fresh()->is_ready_to_checkout);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue