RI cartitems, A exceptions, tests

This commit is contained in:
Fabian @ Blax Software 2025-12-17 12:26:26 +01:00
parent 2f0d0757ee
commit 1b2559b824
7 changed files with 1446 additions and 34 deletions

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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 = [

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}