BFI pool cart

This commit is contained in:
Fabian @ Blax Software 2025-12-20 12:19:34 +01:00
parent 20e6538626
commit 0e6b420297
12 changed files with 914 additions and 80 deletions

View File

@ -284,10 +284,10 @@ return new class extends Migration
$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->integer('price')->default(0); // Stored in cents
$table->integer('price')->nullable(); // Stored in cents, null = unavailable
$table->integer('regular_price')->nullable(); // Stored in cents
$table->integer('unit_amount')->nullable(); // Base unit price for 1 quantity, 1 day (in cents)
$table->integer('subtotal'); // Stored in cents
$table->integer('subtotal')->nullable(); // Stored in cents, null = unavailable
$table->json('parameters')->nullable();
$table->json('meta')->nullable();
$table->timestamp('from')->nullable();

View File

@ -0,0 +1,13 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class CartNotReadyException extends Exception
{
public function __construct(string $message = "Cart is not ready for checkout. Some items may be unavailable.")
{
parent::__construct($message);
}
}

View File

@ -408,8 +408,22 @@ class Cart extends Model
if ($validateAvailability) {
$product = $item->purchasable;
// For pool products, track allocation for total validation
// For pool products, check if allocated by reallocatePoolItems
if ($product instanceof Product && $product->isPool()) {
$meta = $item->getMeta();
$allocatedSingleItemId = $meta->allocated_single_item_id ?? null;
// If this item was NOT allocated (no single assigned), skip updateDates
// to preserve the null price set by reallocatePoolItems
if (empty($allocatedSingleItemId)) {
// Just update the dates without recalculating price
$item->update([
'from' => $itemFrom,
'until' => $itemUntil,
]);
continue;
}
$poolKey = $product->id . '|' . $itemFrom->format('Y-m-d H:i:s') . '|' . $itemUntil->format('Y-m-d H:i:s');
if (!isset($poolValidation[$poolKey])) {
@ -423,19 +437,19 @@ class Cart extends Model
}
$poolValidation[$poolKey]['requested'] += $item->quantity;
$meta = $item->getMeta();
if (isset($meta->allocated_single_item_id)) {
$poolValidation[$poolKey]['allocated'] += $item->quantity;
}
} elseif ($product && !$product->isAvailableForBooking($itemFrom, $itemUntil, $item->quantity)) {
throw new NotEnoughAvailableInTimespanException(
productName: $product->name ?? 'Product',
requested: $item->quantity,
available: 0,
from: $itemFrom,
until: $itemUntil
);
// Non-pool booking item is not available - mark as unavailable
// Don't throw exception - let user adjust dates freely
$item->update([
'from' => $itemFrom,
'until' => $itemUntil,
'price' => null,
'subtotal' => null,
'unit_amount' => null,
]);
// Skip updateDates() since we already set the dates with null price
continue;
}
}
@ -443,21 +457,10 @@ class Cart extends Model
}
}
// Validate pool allocations - all requested items must be allocated
if ($validateAvailability) {
foreach ($poolValidation as $poolData) {
if ($poolData['requested'] > $poolData['allocated']) {
$product = $poolData['product'];
throw new NotEnoughAvailableInTimespanException(
productName: $product->name ?? 'Product',
requested: $poolData['requested'],
available: $poolData['allocated'],
from: $poolData['from'],
until: $poolData['until']
);
}
}
}
// Pool validation is now handled by reallocatePoolItems() which marks
// unallocated items with null price instead of throwing exceptions.
// This allows users to freely adjust dates without exceptions.
// Validation happens at checkout time via isReadyForCheckout().
return $this->fresh();
}
@ -547,6 +550,22 @@ class Cart extends Model
}
if (empty($availableWithPrices)) {
// No singles available for this period - mark ALL pool items as unavailable
foreach ($items as $cartItem) {
// Only update if we should overwrite or item has no dates yet
if (!$overwrite && $cartItem->from && $cartItem->until) {
continue;
}
// Clear allocation and set price to null to indicate unavailable
$cartItem->updateMetaKey('allocated_single_item_id', null);
$cartItem->updateMetaKey('allocated_single_item_name', null);
$cartItem->update([
'price' => null,
'subtotal' => null,
'unit_amount' => null,
]);
}
continue;
}
@ -589,9 +608,16 @@ class Cart extends Model
}
}
// If we couldn't allocate (ran out of available singles), stop
// If we couldn't allocate (ran out of available singles), mark as unavailable
if (!$allocated) {
break;
// Clear allocation and set price to null to indicate unavailable
$cartItem->updateMetaKey('allocated_single_item_id', null);
$cartItem->updateMetaKey('allocated_single_item_name', null);
$cartItem->update([
'price' => null,
'subtotal' => null,
'unit_amount' => null,
]);
}
}
}
@ -605,6 +631,15 @@ class Cart extends Model
* @return void
* @throws NotEnoughAvailableInTimespanException
*/
/**
* Mark booking items as unavailable if they cannot be booked for the given dates.
* Instead of throwing exceptions, this marks items with null price.
*
* @param \DateTimeInterface $from Start date
* @param \DateTimeInterface $until End date
* @param bool $useProvidedDates Whether to use provided dates or item's own dates
* @return void
*/
protected function validateDateAvailability(\DateTimeInterface $from, \DateTimeInterface $until, bool $useProvidedDates = false): void
{
foreach ($this->items as $item) {
@ -617,18 +652,23 @@ class Cart extends Model
continue;
}
// Skip pool products - they are handled by reallocatePoolItems()
if ($product->type === ProductType::POOL) {
continue;
}
// Use provided dates when validating date overwrites, otherwise use item's specific dates
$checkFrom = $useProvidedDates ? $from : ($item->from ?? $from);
$checkUntil = $useProvidedDates ? $until : ($item->until ?? $until);
if (!$product->isAvailableForBooking($checkFrom, $checkUntil, $item->quantity)) {
throw new NotEnoughAvailableInTimespanException(
productName: $product->name ?? 'Product',
requested: $item->quantity,
available: 0, // Could calculate actual available amount
from: $checkFrom,
until: $checkUntil
);
// Mark item as unavailable instead of throwing exception
// This allows users to freely adjust dates
$item->update([
'price' => null,
'subtotal' => null,
'unit_amount' => null,
]);
}
}
}
@ -768,9 +808,25 @@ class Cart extends Model
"Pool product '{$cartable->name}' has only {$availableForThisRequest} items available for the requested period. Requested: {$quantity}"
);
}
} else {
// When dates are not provided, validate against total pool capacity (not current availability)
// This allows adding items even if currently claimed - dates will be validated later
$totalCapacity = $cartable->getPoolTotalCapacity(); // Total capacity ignoring claims
// Subtract items already in cart
$itemsInCart = $this->items()
->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable))
->sum('quantity');
$availableForThisRequest = $totalCapacity === PHP_INT_MAX ? PHP_INT_MAX : max(0, $totalCapacity - $itemsInCart);
if ($availableForThisRequest !== PHP_INT_MAX && $quantity > $availableForThisRequest) {
throw new NotEnoughStockException(
"Pool product '{$cartable->name}' has only {$availableForThisRequest} items available. Requested: {$quantity}"
);
}
}
// When dates are not provided, skip availability validation - allow flexible cart behavior
// The cart will validate when dates are set via setDates()
// Add items one at a time for progressive pricing
$lastCartItem = null;
@ -831,13 +887,27 @@ class Cart extends Model
// If only one date is provided, it's an error
throw new CartDatesRequiredException();
} else {
// When adding pool items without dates, allow adding even if currently unavailable
// Items may be claimed now but available in the future
// Validation will happen when dates are set or at checkout
// This enables flexible booking workflows where users add items first, then select dates
// When adding pool items without dates, validate against total pool capacity
// This allows adding items even if currently claimed - date-based validation happens later
if ($cartable->isPool()) {
$totalCapacity = $cartable->getPoolTotalCapacity(); // Total capacity ignoring claims
// Note: We skip availability validation here for pool products without dates
// The cart will not be ready for checkout without dates anyway
// Subtract items already in cart (without dates or with any dates)
$itemsInCart = $this->items()
->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable))
->sum('quantity');
$availableForThisRequest = $totalCapacity === PHP_INT_MAX ? PHP_INT_MAX : max(0, $totalCapacity - $itemsInCart);
if ($availableForThisRequest !== PHP_INT_MAX && $quantity > $availableForThisRequest) {
throw new NotEnoughStockException(
"Pool product '{$cartable->name}' has only {$availableForThisRequest} items available. Requested: {$quantity}"
);
}
}
// Items may be claimed now but available in the future
// Full date-based validation will happen when dates are set via setDates() or at checkout
}
}
@ -1518,9 +1588,9 @@ class Cart extends Model
*/
public function checkoutSessionLink(array $option = [], ?string $url = null): string|null|false
{
if (! @$this->validateForCheckout(false)) {
return null;
}
// Validate cart - throw exceptions if validation fails
// This ensures users know what's wrong instead of silently returning null
$this->validateForCheckout();
$checkoutSession = $this->checkoutSession($option, $url);

View File

@ -180,6 +180,15 @@ class CartItem extends Model
return false;
}
// Check if item has a valid price (null or <= 0 means unavailable)
if ($this->price === null || $this->price <= 0) {
return false;
}
// Note: Pool items don't require pre-allocation to be ready for checkout.
// The checkout process can allocate singles on-the-fly via claimPoolStock().
// The price check above is sufficient - if price is null, item is unavailable.
// Check if dates are required (for booking products or pools with booking items)
$requiresDates = $product->isBooking() ||
($product->isPool() && $product->hasBookingSingleItems());
@ -323,6 +332,15 @@ class CartItem extends Model
return $adjustments;
}
// Check if price is invalid (null, zero or negative means unavailable)
if ($this->price === null || $this->price <= 0) {
$adjustments['price'] = 'unavailable';
}
// Note: Pool items don't require pre-allocation to be ready for checkout.
// The checkout process can allocate singles on-the-fly via claimPoolStock().
// The price check above is sufficient - if price is null, item is unavailable.
// Check if dates are required (for booking products or pools with booking items)
$requiresDates = $product->isBooking() ||
($product->isPool() && $product->hasBookingSingleItems());

View File

@ -112,6 +112,55 @@ trait MayBePoolProduct
return $availableCount;
}
/**
* Get the total capacity of the pool (sum of all single item stock quantities)
*
* Unlike getPoolMaxQuantity(), this method returns the TOTAL capacity regardless
* of current claims or availability. This is useful for validating cart additions
* without dates - you can't add more items than the pool has single items, even
* if you haven't chosen dates yet.
*
* @return int Total capacity (sum of single item stock quantities)
*/
public function getPoolTotalCapacity(): int
{
if (!$this->isPool()) {
return $this->manage_stock ? ($this->stock_quantity ?? 0) : PHP_INT_MAX;
}
$singleItems = $this->singleProducts;
if ($singleItems->isEmpty()) {
return 0;
}
$hasUnlimitedItem = false;
$total = 0;
foreach ($singleItems as $item) {
if (!$item->manage_stock) {
$hasUnlimitedItem = true;
continue;
}
// Get total stock quantity (not available stock)
// Sum all INCREASE entries to get the total capacity
$itemCapacity = $item->stocks()
->where('type', \Blax\Shop\Enums\StockType::INCREASE->value)
->where('status', \Blax\Shop\Enums\StockStatus::COMPLETED->value)
->sum('quantity');
$total += $itemCapacity;
}
// If ALL items are unlimited, pool is unlimited
if ($hasUnlimitedItem && $total === 0) {
return PHP_INT_MAX;
}
return $total;
}
/**
* Claim stock for a pool product
* This will claim stock from the available single items, respecting the pricing strategy

View File

@ -468,7 +468,7 @@ class CartDateManagementTest extends TestCase
}
/** @test */
public function validate_date_availability_throws_exception_when_product_not_available()
public function validate_date_availability_marks_items_unavailable_when_product_not_available()
{
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
@ -490,13 +490,17 @@ class CartDateManagementTest extends TestCase
// 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);
// Try to set cart dates that overlap - should NOT throw, instead mark items unavailable
$cart->setDates(Carbon::now()->addDays(2), Carbon::now()->addDays(4), validateAvailability: true);
// Item should now be marked as unavailable (null price)
$item->refresh();
$this->assertNull($item->price, 'Unavailable item should have null price');
$this->assertFalse($item->is_ready_to_checkout, 'Unavailable item should not be ready for checkout');
}
/** @test */
public function apply_dates_to_items_throws_exception_when_product_not_available()
public function apply_dates_to_items_marks_items_unavailable_when_product_not_available()
{
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
@ -520,9 +524,13 @@ class CartDateManagementTest extends TestCase
// 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);
// Should NOT throw exception, instead mark items as unavailable
$cart->applyDatesToItems(validateAvailability: true);
// Item should be marked as unavailable (null price)
$item->refresh();
$this->assertNull($item->price, 'Unavailable item should have null price');
$this->assertFalse($item->is_ready_to_checkout, 'Unavailable item should not be ready for checkout');
}
/** @test */

View File

@ -0,0 +1,390 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Workbench\App\Models\User;
/**
* Tests for cart item validation when dates change and items become unavailable.
*
* Bug: When adjusting dates in cart, some cart items show null/0 price because they
* are not available for the new dates. But IsReadyToCheckout incorrectly returns true.
*
* Expected behavior:
* - setDates() should NOT throw - it should allow users to fiddle with dates
* - Items that become unavailable should have price = null
* - Items with null price should NOT be ready for checkout
* - Cart.isReadyForCheckout() should return false if any items are unavailable
* - Exception should only be thrown at checkout time, not when changing dates
*/
class CartItemAvailabilityValidationTest extends TestCase
{
protected User $user;
protected Cart $cart;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
auth()->login($this->user);
$this->cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
}
/**
* Create a pool with limited singles for testing
*/
protected function createPoolWithLimitedSingles(int $numSingles = 3): Product
{
$pool = Product::factory()->create([
'name' => 'Limited Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
ProductPrice::factory()->create([
'purchasable_id' => $pool->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$pool->setPoolPricingStrategy('lowest');
// Create singles with 1 stock each
for ($i = 1; $i <= $numSingles; $i++) {
$single = Product::factory()->create([
'name' => "Single {$i}",
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$single->increaseStock(1);
ProductPrice::factory()->create([
'purchasable_id' => $single->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$pool->attachSingleItems([$single->id]);
}
return $pool;
}
/** @test */
public function cart_item_with_null_price_is_not_ready_for_checkout()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add 3 items without dates
$this->cart->addToCart($pool, 3);
// Manually set one item's price to null to simulate unavailable item
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
$item->refresh();
// Item with null price should NOT be ready for checkout
$this->assertNull($item->price);
$this->assertFalse($item->is_ready_to_checkout, 'Item with null price should not be ready for checkout');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout, 'Cart with null-price item should not be ready');
}
/** @test */
public function cart_item_with_zero_price_is_not_ready_for_checkout()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add 3 items without dates
$this->cart->addToCart($pool, 3);
// Manually set one item's price to 0 to simulate unavailable item
$item = $this->cart->items()->first();
$item->update(['price' => 0, 'subtotal' => 0]);
$item->refresh();
// Item with 0 price should NOT be ready for checkout
$this->assertEquals(0, $item->price);
$this->assertFalse($item->is_ready_to_checkout, 'Item with price 0 should not be ready for checkout');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout, 'Cart with 0-price item should not be ready');
}
/** @test */
public function unallocated_pool_item_with_null_price_is_not_ready_for_checkout()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 3 items with dates - all should be allocated
$this->cart->addToCart($pool, 3, [], $from, $until);
// Manually simulate an item becoming unavailable:
// - Remove allocation
// - Set price to null (the real indicator of unavailability)
$item = $this->cart->items()->first();
$meta = $item->getMeta();
unset($meta->allocated_single_item_id);
unset($meta->allocated_single_item_name);
$item->update([
'meta' => json_encode($meta),
'price' => null,
'subtotal' => null,
]);
$item->refresh();
// Item with null price should NOT be ready for checkout
$this->assertFalse($item->is_ready_to_checkout, 'Item with null price should not be ready for checkout');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout, 'Cart with unavailable item should not be ready');
}
/** @test */
public function setDates_does_not_throw_when_items_become_unavailable()
{
$pool = $this->createPoolWithLimitedSingles(3);
// First user books all 3 singles for specific dates
$user1 = User::factory()->create();
$user1Cart = $user1->currentCart();
$bookedFrom = now()->addDays(5);
$bookedUntil = now()->addDays(6);
$user1Cart->addToCart($pool, 3, [], $bookedFrom, $bookedUntil);
$user1Cart->checkout(); // Claims the stock
// Our user adds items without dates (should work - we have 3 total capacity)
$this->cart->addToCart($pool, 3);
// All items should have prices > 0 initially
foreach ($this->cart->items as $item) {
$this->assertGreaterThan(0, $item->price, 'Item should have positive price initially');
}
// Now set dates that conflict with the booked period
// This should NOT throw - it should just mark items as unavailable
$this->cart->setDates($bookedFrom, $bookedUntil);
$this->cart->refresh();
$this->cart->load('items');
// Cart should NOT be ready for checkout (items are unavailable)
$this->assertFalse(
$this->cart->is_ready_to_checkout,
'Cart should not be ready when items are unavailable for selected dates'
);
}
/** @test */
public function partial_availability_marks_some_items_unavailable()
{
$pool = $this->createPoolWithLimitedSingles(3);
// First user books 2 of 3 singles for specific dates
$user1 = User::factory()->create();
$user1Cart = $user1->currentCart();
$bookedFrom = now()->addDays(5);
$bookedUntil = now()->addDays(6);
$user1Cart->addToCart($pool, 2, [], $bookedFrom, $bookedUntil);
$user1Cart->checkout(); // Claims 2 singles
// Verify that only 1 single is available for the booked period
$available = $pool->getPoolMaxQuantity($bookedFrom, $bookedUntil);
$this->assertEquals(1, $available, 'Only 1 single should be available after booking 2');
// Our user adds 3 items without dates
$this->cart->addToCart($pool, 3);
$this->assertEquals(3, $this->cart->items()->sum('quantity'));
// Set dates where only 1 single is available
// Should NOT throw - just mark some items as unavailable
$this->cart->setDates($bookedFrom, $bookedUntil);
$this->cart->refresh();
$this->cart->load('items');
// Check how many items are available vs unavailable
$availableItems = $this->cart->items->filter(
fn($item) =>
$item->price !== null && $item->price > 0
);
$unavailableItems = $this->cart->items->filter(
fn($item) =>
$item->price === null || $item->price <= 0
);
// Should have 1 available and 2 unavailable
$this->assertEquals(1, $availableItems->count(), 'Should have 1 available item');
$this->assertEquals(2, $unavailableItems->count(), 'Should have 2 unavailable items');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->is_ready_to_checkout, 'Cart with unavailable items should not be ready');
}
/** @test */
public function cart_item_without_allocated_single_for_pool_is_not_ready()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 3 items with dates
$this->cart->addToCart($pool, 3, [], $from, $until);
// Verify all items are allocated and ready
foreach ($this->cart->items as $item) {
$meta = $item->getMeta();
$this->assertNotNull($meta->allocated_single_item_id ?? null, 'Item should be allocated');
$this->assertTrue($item->is_ready_to_checkout, 'Allocated item should be ready');
}
// All items ready - cart is ready
$this->assertTrue($this->cart->fresh()->is_ready_to_checkout);
}
/** @test */
public function removing_unavailable_items_makes_cart_ready()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add 3 items without dates
$this->cart->addToCart($pool, 3);
// Manually make one item unavailable (price = null)
$unavailableItem = $this->cart->items()->first();
$unavailableItem->update(['price' => null, 'subtotal' => null]);
// Cart should NOT be ready
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout);
// Remove the unavailable item
$unavailableItem->delete();
// Set dates for remaining items
$from = now()->addDays(1);
$until = now()->addDays(2);
$this->cart->setDates($from, $until);
// Now cart should be ready
$this->assertTrue($this->cart->fresh()->is_ready_to_checkout);
}
/** @test */
public function getItemsRequiringAdjustments_includes_null_price_items()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 3 items with dates
$this->cart->addToCart($pool, 3, [], $from, $until);
// Make one item have null price
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
$this->cart->refresh();
$this->cart->load('items');
// Get items requiring adjustments
$itemsNeedingAdjustment = $this->cart->getItemsRequiringAdjustments();
// The null-price item should be in the list
$this->assertGreaterThanOrEqual(
1,
$itemsNeedingAdjustment->count(),
'Null price item should require adjustment'
);
// Check that it has 'unavailable' as the price adjustment reason
$nullPriceItem = $itemsNeedingAdjustment->first(fn($i) => $i->price === null);
$this->assertNotNull($nullPriceItem, 'Should find the null-price item');
$adjustments = $nullPriceItem->requiredAdjustments();
$this->assertArrayHasKey('price', $adjustments);
$this->assertEquals('unavailable', $adjustments['price']);
}
/** @test */
public function changing_dates_to_available_period_makes_items_available_again()
{
$pool = $this->createPoolWithLimitedSingles(3);
// First user books all 3 singles for specific dates
$user1 = User::factory()->create();
$user1Cart = $user1->currentCart();
$bookedFrom = now()->addDays(5);
$bookedUntil = now()->addDays(6);
$user1Cart->addToCart($pool, 3, [], $bookedFrom, $bookedUntil);
$user1Cart->checkout();
// Our user adds 3 items without dates
$this->cart->addToCart($pool, 3);
// Set dates that conflict - items become unavailable
$this->cart->setDates($bookedFrom, $bookedUntil);
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout);
// Change to different dates where all singles are available
$availableFrom = now()->addDays(10);
$availableUntil = now()->addDays(11);
$this->cart->setDates($availableFrom, $availableUntil);
$this->cart->refresh();
$this->cart->load('items');
// All items should now have valid prices
foreach ($this->cart->items as $item) {
$this->assertNotNull($item->price, 'Item should have price after changing to available dates');
$this->assertGreaterThan(0, $item->price, 'Item should have positive price');
}
// Cart should be ready for checkout
$this->assertTrue($this->cart->is_ready_to_checkout, 'Cart should be ready after changing to available dates');
}
/** @test */
public function checkout_throws_when_items_are_unavailable()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add items and make one unavailable
$this->cart->addToCart($pool, 3);
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
// Trying to checkout should throw CartItemMissingInformationException
// because the item has 'price' => 'unavailable' in requiredAdjustments()
$this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class);
$this->cart->checkout();
}
}

View File

@ -0,0 +1,250 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Workbench\App\Models\User;
/**
* Tests to ensure pool products cannot exceed available single items
* even when adding without dates (flexible cart behavior)
*/
class PoolMaxQuantityValidationTest extends TestCase
{
protected User $user;
protected Cart $cart;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
auth()->login($this->user);
$this->cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
}
/**
* Create a pool with 7 single items (production scenario)
*/
protected function createPoolWith7Singles(): Product
{
$pool = Product::factory()->create([
'name' => 'Production Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
ProductPrice::factory()->create([
'purchasable_id' => $pool->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$pool->setPoolPricingStrategy('lowest');
// Create 7 single items, each with 1 stock
for ($i = 1; $i <= 7; $i++) {
$single = Product::factory()->create([
'name' => "Single {$i}",
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$single->increaseStock(1);
ProductPrice::factory()->create([
'purchasable_id' => $single->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$pool->attachSingleItems([$single->id]);
}
return $pool;
}
/** @test */
public function cannot_add_more_items_than_available_singles_without_dates()
{
$pool = $this->createPoolWith7Singles();
// Pool has 7 single items, each with 1 stock
$this->assertEquals(7, $pool->getPoolMaxQuantity());
// Should be able to add 7 items
$this->cart->addToCart($pool, 7);
$this->assertEquals(7, $this->cart->fresh()->items->sum('quantity'));
// Should NOT be able to add 8th item - should throw exception
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
$this->expectExceptionMessage('has only 0 items available');
$this->cart->addToCart($pool, 1);
}
/** @test */
public function cannot_add_more_items_than_available_singles_with_dates()
{
$pool = $this->createPoolWith7Singles();
$from = now()->addDays(1);
$until = now()->addDays(2);
// Pool has 7 single items, each with 1 stock
$this->assertEquals(7, $pool->getPoolMaxQuantity($from, $until));
// Should be able to add 7 items with dates
$this->cart->addToCart($pool, 7, [], $from, $until);
$this->assertEquals(7, $this->cart->fresh()->items->sum('quantity'));
// Should NOT be able to add 8th item - should throw exception
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
$this->expectExceptionMessage('has only 0 items available');
$this->cart->addToCart($pool, 1, [], $from, $until);
}
/** @test */
public function cannot_add_batch_exceeding_available_singles_without_dates()
{
$pool = $this->createPoolWith7Singles();
// Trying to add 8 items at once should fail
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
$this->expectExceptionMessage('has only 7 items available');
$this->cart->addToCart($pool, 8);
}
/** @test */
public function cannot_add_batch_exceeding_available_singles_with_dates()
{
$pool = $this->createPoolWith7Singles();
$from = now()->addDays(1);
$until = now()->addDays(2);
// Trying to add 8 items at once should fail
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
$this->expectExceptionMessage('has only 7 items available');
$this->cart->addToCart($pool, 8, [], $from, $until);
}
/** @test */
public function adding_items_without_dates_then_adding_more_validates_correctly()
{
$pool = $this->createPoolWith7Singles();
// Add 5 items without dates
$this->cart->addToCart($pool, 5);
$this->assertEquals(5, $this->cart->fresh()->items->sum('quantity'));
// Should be able to add 2 more (total 7)
$this->cart->addToCart($pool, 2);
$this->assertEquals(7, $this->cart->fresh()->items->sum('quantity'));
// Should NOT be able to add 1 more
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
$this->cart->addToCart($pool, 1);
}
/** @test */
public function checkoutSessionLink_throws_exception_when_cart_invalid()
{
$pool = $this->createPoolWith7Singles();
// Add items without dates - cart is not ready for checkout
$this->cart->addToCart($pool, 3);
// checkoutSessionLink should throw exception, not return null
// When items don't have dates, validation throws CartItemMissingInformationException
$this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class);
$this->expectExceptionMessage('is missing required information: from, until');
$this->cart->checkoutSessionLink();
}
/** @test */
public function checkoutSessionLink_throws_exception_when_not_enough_stock()
{
$pool = $this->createPoolWith7Singles();
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 7 items with dates
$this->cart->addToCart($pool, 7, [], $from, $until);
// Simulate another cart claiming all stock for the same period
$otherCart = Cart::factory()->create([
'customer_id' => User::factory()->create()->id,
'customer_type' => User::class,
]);
$otherCart->addToCart($pool, 7, [], $from, $until);
$otherCart->checkout(); // This claims the stock
// Our cart should now fail validation when trying to create checkout session
// The validation throws NotEnoughStockException when checking availability
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
$this->expectExceptionMessage('has only 0 items available');
$this->cart->fresh()->checkoutSessionLink();
}
/** @test */
public function cart_aware_validation_accounts_for_items_already_in_cart()
{
$pool = $this->createPoolWith7Singles();
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 5 items to cart
$this->cart->addToCart($pool, 5, [], $from, $until);
// Pool has 7 total, 5 in cart, so 2 available for this request
$this->assertEquals(7, $pool->getPoolMaxQuantity($from, $until));
// Should be able to add 2 more
$this->cart->addToCart($pool, 2, [], $from, $until);
$this->assertEquals(7, $this->cart->fresh()->items->sum('quantity'));
// Should NOT be able to add 1 more
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
$this->expectExceptionMessage('has only 0 items available');
$this->cart->addToCart($pool, 1, [], $from, $until);
}
/** @test */
public function validation_message_shows_correct_remaining_availability()
{
$pool = $this->createPoolWith7Singles();
// Add 5 items without dates
$this->cart->addToCart($pool, 5);
try {
// Try to add 5 more (total would be 10, but max is 7)
// Should fail saying only 2 available
$this->cart->addToCart($pool, 5);
$this->fail('Should have thrown NotEnoughStockException');
} catch (\Blax\Shop\Exceptions\NotEnoughStockException $e) {
$this->assertStringContainsString('has only 2 items available', $e->getMessage());
$this->assertStringContainsString('Requested: 5', $e->getMessage());
}
}
}

View File

@ -315,7 +315,7 @@ class PoolParkingCartPricingTest extends TestCase
}
/** @test */
public function config_a_validates_availability_when_setting_dates()
public function config_a_marks_items_unavailable_when_setting_dates_to_unavailable_period()
{
$this->cart = $this->createCart();
['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false);
@ -334,8 +334,13 @@ class PoolParkingCartPricingTest extends TestCase
$spots[2]->claimStock(2, null, $from, $until);
// Try to set dates for period when no stock is available
$this->expectException(\Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException::class);
// Should NOT throw, but mark items as unavailable
$this->cart->setDates($from, $until, validateAvailability: true);
// Item should be marked as unavailable (null price)
$item = $this->cart->items()->first();
$this->assertNull($item->price, 'Unavailable item should have null price');
$this->assertFalse($item->is_ready_to_checkout, 'Unavailable item should not be ready for checkout');
}
// ==========================================
@ -667,7 +672,7 @@ class PoolParkingCartPricingTest extends TestCase
// ==========================================
/** @test */
public function set_dates_validates_availability_for_each_cart_item()
public function set_dates_marks_items_unavailable_when_all_claimed()
{
$this->cart = $this->createCart();
['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false);
@ -686,10 +691,17 @@ class PoolParkingCartPricingTest extends TestCase
$spots[1]->claimStock(2, null, $from, $until);
$spots[2]->claimStock(2, null, $from, $until);
// Setting dates should validate and throw exception
// because ALL spots are claimed for this period and we need 5
$this->expectException(\Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException::class);
// Setting dates should NOT throw, but mark items as unavailable
$this->cart->setDates($from, $until, validateAvailability: true);
// All items should be marked as unavailable (null price)
$this->cart->refresh();
$this->cart->load('items');
foreach ($this->cart->items as $item) {
$this->assertNull($item->price, 'Unavailable item should have null price');
$this->assertFalse($item->is_ready_to_checkout, 'Unavailable item should not be ready');
}
$this->assertFalse($this->cart->is_ready_to_checkout, 'Cart should not be ready');
}
/** @test */

View File

@ -428,16 +428,24 @@ class PoolProductionBugTest extends TestCase
$this->assertFalse($secondCart->isReadyForCheckout());
$this->assertFalse($secondCart->IsReadyToCheckout);
$this->assertThrows(
fn() => $secondCart->setDates($from1, $until1),
\Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException::class
);
// Setting dates to a fully booked period should NOT throw,
// but mark items as unavailable instead
$secondCart->setDates($from1, $until1);
// All items should be marked as unavailable
$secondCart->refresh();
$secondCart->load('items');
foreach ($secondCart->items as $item) {
$this->assertNull($item->price, 'Item should have null price for unavailable period');
$this->assertFalse($item->is_ready_to_checkout);
}
$this->assertFalse($secondCart->isReadyForCheckout());
// Now second user tries different dates - should succeed
$from2 = Carbon::tomorrow()->addDays(2)->startOfDay();
$until2 = Carbon::tomorrow()->addDays(3)->startOfDay(); // 1 day later
// This should work without exception
// This should work - items become available again with new dates
$secondCart->setDates($from2, $until2);
$this->assertTrue($secondCart->isReadyForCheckout());
$this->assertTrue($secondCart->isReadyToCheckout);

View File

@ -167,7 +167,7 @@ class PoolSmartAllocationTest extends TestCase
}
/**
* Test: User1 purchases items, User2 can add same items for different dates
* Test: User1 purchases items, User2 can add same items but only available ones get allocated
*/
/** @test */
public function user2_can_book_same_items_for_different_dates_after_user1_purchase()
@ -179,6 +179,7 @@ class PoolSmartAllocationTest extends TestCase
$purchaseFrom = Carbon::yesterday()->startOfDay();
$purchaseUntil = Carbon::tomorrow()->addDay()->startOfDay();
// User1 books 5 of 6 available singles
$user1Cart->addToCart($this->pool, 5, [], $purchaseFrom, $purchaseUntil);
$user1Cart->checkout();
@ -194,9 +195,22 @@ class PoolSmartAllocationTest extends TestCase
$this->assertEquals(6, $user2Cart->fresh()->items->sum('quantity'));
$this->assertFalse($user2Cart->fresh()->isReadyForCheckout(), 'Cart should not be ready without dates');
// User2 tries to set dates that conflict with User1
$this->expectException(\Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException::class);
// User2 sets dates that conflict with User1's booking
// Only 1 single is still available (User1 took 5)
$user2Cart->setDates($purchaseFrom, $purchaseUntil);
// 5 items should be unavailable (null price), 1 should be available
$user2Cart->refresh();
$user2Cart->load('items');
$availableItems = $user2Cart->items->filter(fn($item) => $item->price !== null && $item->price > 0);
$unavailableItems = $user2Cart->items->filter(fn($item) => $item->price === null);
$this->assertEquals(1, $availableItems->count(), 'Should have 1 available item (6th single not booked by user1)');
$this->assertEquals(5, $unavailableItems->count(), 'Should have 5 unavailable items (user1 booked those singles)');
// Cart should NOT be ready (has unavailable items)
$this->assertFalse($user2Cart->isReadyForCheckout(), 'Cart should not be ready with unavailable items');
}
/**

View File

@ -234,29 +234,31 @@ class CartTest extends TestCase
}
/** @test */
public function checkout_session_link_is_null_when_stripe_disabled()
public function checkout_session_link_throws_when_stripe_disabled()
{
config(['shop.stripe.enabled' => false]);
$cart = Cart::create();
$this->assertNull($cart->checkoutSessionLink());
// Now throws CartEmptyException (validation happens before stripe check)
$this->expectException(\Blax\Shop\Exceptions\CartEmptyException::class);
$cart->checkoutSessionLink();
}
/** @test */
public function checkout_session_link_returns_null_when_no_session_exists()
public function checkout_session_link_throws_when_cart_empty()
{
config(['shop.stripe.enabled' => true]);
$cart = Cart::create();
$link = $cart->checkoutSessionLink();
$this->assertNull($link);
// Now throws CartEmptyException instead of returning null
$this->expectException(\Blax\Shop\Exceptions\CartEmptyException::class);
$cart->checkoutSessionLink();
}
/** @test */
public function checkout_session_link_returns_null_when_session_id_empty()
public function checkout_session_link_throws_when_cart_empty_even_with_meta()
{
config(['shop.stripe.enabled' => true]);
@ -264,9 +266,9 @@ class CartTest extends TestCase
'meta' => ['other_data' => 'value'],
]);
$link = $cart->checkoutSessionLink();
$this->assertNull($link);
// Now throws CartEmptyException instead of returning null
$this->expectException(\Blax\Shop\Exceptions\CartEmptyException::class);
$cart->checkoutSessionLink();
}
/** @test */