From 0e6b420297e4c547051f86f5fabec0818544d473 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Sat, 20 Dec 2025 12:19:34 +0100 Subject: [PATCH] BFI pool cart --- .../create_blax_shop_tables.php.stub | 4 +- src/Exceptions/CartNotReadyException.php | 13 + src/Models/Cart.php | 166 +++++--- src/Models/CartItem.php | 18 + src/Traits/MayBePoolProduct.php | 49 +++ tests/Feature/CartDateManagementTest.php | 20 +- .../CartItemAvailabilityValidationTest.php | 390 ++++++++++++++++++ .../Feature/PoolMaxQuantityValidationTest.php | 250 +++++++++++ tests/Feature/PoolParkingCartPricingTest.php | 24 +- tests/Feature/PoolProductionBugTest.php | 18 +- tests/Feature/PoolSmartAllocationTest.php | 20 +- tests/Unit/CartTest.php | 22 +- 12 files changed, 914 insertions(+), 80 deletions(-) create mode 100644 src/Exceptions/CartNotReadyException.php create mode 100644 tests/Feature/CartItemAvailabilityValidationTest.php create mode 100644 tests/Feature/PoolMaxQuantityValidationTest.php diff --git a/database/migrations/create_blax_shop_tables.php.stub b/database/migrations/create_blax_shop_tables.php.stub index 079686c..67d6283 100644 --- a/database/migrations/create_blax_shop_tables.php.stub +++ b/database/migrations/create_blax_shop_tables.php.stub @@ -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(); diff --git a/src/Exceptions/CartNotReadyException.php b/src/Exceptions/CartNotReadyException.php new file mode 100644 index 0000000..1bf1f3c --- /dev/null +++ b/src/Exceptions/CartNotReadyException.php @@ -0,0 +1,13 @@ +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; - } + $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); diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index 2aa47fa..5ba279d 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -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()); diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php index 5682b5f..65da56a 100644 --- a/src/Traits/MayBePoolProduct.php +++ b/src/Traits/MayBePoolProduct.php @@ -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 diff --git a/tests/Feature/CartDateManagementTest.php b/tests/Feature/CartDateManagementTest.php index 4e59cca..fe60215 100644 --- a/tests/Feature/CartDateManagementTest.php +++ b/tests/Feature/CartDateManagementTest.php @@ -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 */ diff --git a/tests/Feature/CartItemAvailabilityValidationTest.php b/tests/Feature/CartItemAvailabilityValidationTest.php new file mode 100644 index 0000000..c11f0e9 --- /dev/null +++ b/tests/Feature/CartItemAvailabilityValidationTest.php @@ -0,0 +1,390 @@ +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(); + } +} diff --git a/tests/Feature/PoolMaxQuantityValidationTest.php b/tests/Feature/PoolMaxQuantityValidationTest.php new file mode 100644 index 0000000..8542fb4 --- /dev/null +++ b/tests/Feature/PoolMaxQuantityValidationTest.php @@ -0,0 +1,250 @@ +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()); + } + } +} diff --git a/tests/Feature/PoolParkingCartPricingTest.php b/tests/Feature/PoolParkingCartPricingTest.php index 5f1a71f..099782a 100644 --- a/tests/Feature/PoolParkingCartPricingTest.php +++ b/tests/Feature/PoolParkingCartPricingTest.php @@ -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 */ diff --git a/tests/Feature/PoolProductionBugTest.php b/tests/Feature/PoolProductionBugTest.php index 44e5bf4..abf6aee 100644 --- a/tests/Feature/PoolProductionBugTest.php +++ b/tests/Feature/PoolProductionBugTest.php @@ -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); diff --git a/tests/Feature/PoolSmartAllocationTest.php b/tests/Feature/PoolSmartAllocationTest.php index a5387ff..6f48ccc 100644 --- a/tests/Feature/PoolSmartAllocationTest.php +++ b/tests/Feature/PoolSmartAllocationTest.php @@ -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'); } /** diff --git a/tests/Unit/CartTest.php b/tests/Unit/CartTest.php index 218bab7..08084ff 100644 --- a/tests/Unit/CartTest.php +++ b/tests/Unit/CartTest.php @@ -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 */