From dbc297122ec125bbec5f9dade6db18d669234a18 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Fri, 19 Dec 2025 12:25:59 +0100 Subject: [PATCH] BF pool cart --- src/Models/Cart.php | 17 +- src/Models/Product.php | 6 + src/Traits/MayBePoolProduct.php | 24 +- tests/Feature/PoolParkingCartPricingTest.php | 792 +++++++++++++++++++ tests/Feature/PoolPerMinutePricingTest.php | 12 +- 5 files changed, 820 insertions(+), 31 deletions(-) create mode 100644 tests/Feature/PoolParkingCartPricingTest.php diff --git a/src/Models/Cart.php b/src/Models/Cart.php index d83ed91..c903262 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -234,7 +234,8 @@ class Cart extends Model } if ($validateAvailability) { - $this->validateDateAvailability($from, $until); + // When overwriting item dates, validate against the new cart dates + $this->validateDateAvailability($from, $until, $overwrite_item_dates); } // Update cart with from/until @@ -368,7 +369,7 @@ class Cart extends Model * @return void * @throws NotEnoughAvailableInTimespanException */ - protected function validateDateAvailability(\DateTimeInterface $from, \DateTimeInterface $until): void + protected function validateDateAvailability(\DateTimeInterface $from, \DateTimeInterface $until, bool $useProvidedDates = false): void { foreach ($this->items as $item) { if (!$item->is_booking) { @@ -380,9 +381,9 @@ class Cart extends Model continue; } - // Use item's specific dates if set, otherwise use the dates being validated - $checkFrom = $item->from ?? $from; - $checkUntil = $item->until ?? $until; + // 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( @@ -644,10 +645,10 @@ class Cart extends Model // Calculate expected price for this item $poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until); $expectedPrice = $poolItemData['price'] ?? null; - + // Only merge if price_id matches AND the price amount matches - $priceMatch = $poolPriceId && $item->price_id === $poolPriceId && - $expectedPrice !== null && $item->unit_amount === (int) round($expectedPrice); + $priceMatch = $poolPriceId && $item->price_id === $poolPriceId && + $expectedPrice !== null && $item->unit_amount === (int) round($expectedPrice); } return $paramsMatch && $datesMatch && $priceMatch; diff --git a/src/Models/Product.php b/src/Models/Product.php index 2176cbf..44979c8 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -326,6 +326,12 @@ class Product extends Model implements Purchasable, Cartable */ public function isAvailableForBooking(\DateTimeInterface $from, \DateTimeInterface $until, int $quantity = 1): bool { + // For pool products, delegate to pool-specific availability checking + if ($this->isPool()) { + $available = $this->getPoolMaxQuantity($from, $until); + return $available === PHP_INT_MAX || $available >= $quantity; + } + if (!$this->manage_stock) { return true; } diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php index cd054a6..ce164fb 100644 --- a/src/Traits/MayBePoolProduct.php +++ b/src/Traits/MayBePoolProduct.php @@ -789,25 +789,11 @@ trait MayBePoolProduct } } - // Also add pool's direct price if it has one - if ($this->hasPrice()) { - $poolPriceModel = $this->defaultPrice()->first(); - $poolPrice = $poolPriceModel?->getCurrentPrice($sales_price ?? $this->isOnSale()); - if ($poolPrice !== null) { - $poolPriceRounded = round($poolPrice, 2); - $usedAtPoolPrice = $priceUsage[$poolPriceRounded] ?? 0; - - // Pool price is typically unlimited (doesn't manage stock) - if (!$this->manage_stock) { - $availableItems[] = [ - 'price' => $poolPrice, - 'quantity' => PHP_INT_MAX, - 'item' => $this, - 'price_id' => $poolPriceModel?->id, - ]; - } - } - } + // Note: Pool's own price is ONLY used as fallback for single items without prices. + // We do NOT add the pool itself as a separate "unlimited" item. + // This ensures total stock is limited to the sum of single item stocks. + // The fallback logic is already handled above (lines 768-771) where single items + // without prices use the pool's price instead. if (empty($availableItems)) { return null; diff --git a/tests/Feature/PoolParkingCartPricingTest.php b/tests/Feature/PoolParkingCartPricingTest.php new file mode 100644 index 0000000..3e19c0e --- /dev/null +++ b/tests/Feature/PoolParkingCartPricingTest.php @@ -0,0 +1,792 @@ +user = User::factory()->create(); + auth()->login($this->user); + } + + /** + * Create the pool product with specified configuration + * + * @param bool $hasPoolPrice Whether pool has its own price + * @param bool $poolManagesStock Whether pool manages stock + * @return array{pool: Product, spots: array} + */ + protected function createParkingPool(bool $hasPoolPrice, bool $poolManagesStock): array + { + // Create pool product + $pool = Product::factory()->create([ + 'name' => 'Parkings', + 'type' => ProductType::POOL, + 'manage_stock' => $poolManagesStock, + ]); + + // Set pricing strategy to lowest + $pool->setPoolPricingStrategy('lowest'); + + // Pool price (500) - only if hasPoolPrice + if ($hasPoolPrice) { + ProductPrice::factory()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 500, + 'currency' => 'USD', + 'is_default' => true, + ]); + } + + // Create single items + $spot1 = Product::factory()->create([ + 'name' => 'Parking Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(2); + + // Spot 1 has default price of 300 + ProductPrice::factory()->create([ + 'purchasable_id' => $spot1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 300, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $spot2 = Product::factory()->create([ + 'name' => 'Parking Spot 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot2->increaseStock(2); + // Spot 2 does NOT have a default price - should fallback to pool price + + $spot3 = Product::factory()->create([ + 'name' => 'Parking Spot 3', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot3->increaseStock(2); + + // Spot 3 has default price of 1000 + ProductPrice::factory()->create([ + 'purchasable_id' => $spot3->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Attach single items to pool + $pool->attachSingleItems([$spot1->id, $spot2->id, $spot3->id]); + + return [ + 'pool' => $pool, + 'spots' => [$spot1, $spot2, $spot3], + ]; + } + + /** + * Create a fresh cart for testing + */ + protected function createCart(): Cart + { + return Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + } + + // ========================================== + // Configuration A: Pool HAS price, does NOT manage stock + // ========================================== + + /** @test */ + public function config_a_progressive_pricing_step_by_step() + { + $this->cart = $this->createCart(); + ['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false); + + // Add 1: Should use lowest price (300 from Spot 1) + $cartItem = $this->cart->addToCart($pool, 1); + $this->assertEquals(300, $this->cart->getTotal()); + $this->assertEquals(300, $cartItem->price); + + // Add 2: Still lowest price (300), cumulative 600 + $this->cart->addToCart($pool, 1); + $this->assertEquals(600, $this->cart->fresh()->getTotal()); + + // Add 3: Next lowest is pool price (500), cumulative 1100 + $this->cart->addToCart($pool, 1); + $this->assertEquals(1100, $this->cart->fresh()->getTotal()); + + // Add 4: Pool price again (500), cumulative 1600 + $this->cart->addToCart($pool, 1); + $this->assertEquals(1600, $this->cart->fresh()->getTotal()); + + // Add 5: Spot 3 price (1000), cumulative 2600 + $this->cart->addToCart($pool, 1); + $this->assertEquals(2600, $this->cart->fresh()->getTotal()); + + // Add 6: Spot 3 price again (1000), cumulative 3600 + $this->cart->addToCart($pool, 1); + $this->assertEquals(3600, $this->cart->fresh()->getTotal()); + + // Add 7: Should throw exception - no more stock + $this->expectException(NotEnoughStockException::class); + $this->cart->addToCart($pool, 1); + } + + /** @test */ + public function config_a_cart_items_have_correct_price_ids() + { + $this->cart = $this->createCart(); + ['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false); + + // Get price IDs for reference + $spot1PriceId = $spots[0]->defaultPrice()->first()->id; + $poolPriceId = $pool->defaultPrice()->first()->id; + $spot3PriceId = $spots[2]->defaultPrice()->first()->id; + + // Add 6 items + $this->cart->addToCart($pool, 6); + + $items = $this->cart->items()->orderBy('price', 'asc')->get(); + + // First cart item group (price 300) should have Spot 1's price_id + $item300 = $items->first(fn($i) => $i->price === 300); + $this->assertNotNull($item300); + $this->assertEquals($spot1PriceId, $item300->price_id); + + // Second cart item group (price 500) should have Pool's price_id (for Spot 2 fallback) + $item500 = $items->first(fn($i) => $i->price === 500); + $this->assertNotNull($item500); + $this->assertEquals($poolPriceId, $item500->price_id); + + // Third cart item group (price 1000) should have Spot 3's price_id + $item1000 = $items->first(fn($i) => $i->price === 1000); + $this->assertNotNull($item1000); + $this->assertEquals($spot3PriceId, $item1000->price_id); + } + + /** @test */ + public function config_a_set_dates_doubles_cart_total() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false); + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days + + // Add items with dates + $this->cart->addToCart($pool, 6, [], $from, $until); + + // With 2 days: 300*2 + 300*2 + 500*2 + 500*2 + 1000*2 + 1000*2 = 7200 + $this->assertEquals(7200, $this->cart->fresh()->getTotal()); + } + + /** @test */ + public function config_a_set_dates_after_adding_recalculates_prices() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false); + + // Add items without dates first + $this->cart->addToCart($pool, 6); + + // 1-day prices: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600 + $this->assertEquals(3600, $this->cart->fresh()->getTotal()); + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days + + // Set dates - should recalculate to 2-day prices + $this->cart->setDates($from, $until, validateAvailability: false); + + // 2-day prices: (300 + 300 + 500 + 500 + 1000 + 1000) * 2 = 7200 + $this->assertEquals(7200, $this->cart->fresh()->getTotal()); + } + + /** @test */ + public function config_a_set_from_date_and_until_date_separately() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false); + + // Add items without dates first + $this->cart->addToCart($pool, 6); + + $this->assertEquals(3600, $this->cart->fresh()->getTotal()); + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days + + // Set from date first + $this->cart->setFromDate($from, validateAvailability: false); + + // Then set until date - this should trigger recalculation + $this->cart->setUntilDate($until, validateAvailability: false); + + // Apply dates to items + $this->cart->applyDatesToItems(validateAvailability: false, overwrite: true); + + // Should be 2-day prices + $this->assertEquals(7200, $this->cart->fresh()->getTotal()); + } + + /** @test */ + public function config_a_set_dates_overwrites_cart_item_dates() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false); + + $from1 = Carbon::now()->addDay()->startOfDay(); + $until1 = Carbon::now()->addDays(2)->startOfDay(); // 1 day + + // Add items with 1-day dates + $this->cart->addToCart($pool, 2, [], $from1, $until1); + $this->assertEquals(600, $this->cart->fresh()->getTotal()); // 300 * 1 * 2 + + $from2 = Carbon::now()->addDay()->startOfDay(); + $until2 = Carbon::now()->addDays(4)->startOfDay(); // 3 days + + // Set new cart dates - should overwrite item dates + $this->cart->setDates($from2, $until2, validateAvailability: false, overwrite_item_dates: true); + + // Should be 3-day prices: 300*3 + 300*3 = 1800 + $this->assertEquals(1800, $this->cart->fresh()->getTotal()); + + // Verify item dates were overwritten + foreach ($this->cart->items as $item) { + $this->assertEquals($from2->format('Y-m-d'), $item->from->format('Y-m-d')); + $this->assertEquals($until2->format('Y-m-d'), $item->until->format('Y-m-d')); + } + } + + /** @test */ + public function config_a_validates_availability_when_setting_dates() + { + $this->cart = $this->createCart(); + ['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false); + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(2)->startOfDay(); + + // Add items WITH dates first (so they become booking items) + $from2 = Carbon::now()->addDays(5)->startOfDay(); + $until2 = Carbon::now()->addDays(6)->startOfDay(); + $this->cart->addToCart($pool, 1, [], $from2, $until2); + + // Claim all stock for the NEW period we want to set + $spots[0]->claimStock(2, null, $from, $until); + $spots[1]->claimStock(2, null, $from, $until); + $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); + $this->cart->setDates($from, $until, validateAvailability: true); + } + + // ========================================== + // Configuration B: Pool does NOT have price, does NOT manage stock + // ========================================== + + /** @test */ + public function config_b_progressive_pricing_step_by_step() + { + $this->cart = $this->createCart(); + ['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: false, poolManagesStock: false); + + // Add 1: Should use lowest price (300 from Spot 1) + $cartItem = $this->cart->addToCart($pool, 1); + $this->assertEquals(300, $this->cart->getTotal()); + $this->assertEquals(300, $cartItem->price); + + // Add 2: Still lowest price (300), cumulative 600 + $this->cart->addToCart($pool, 1); + $this->assertEquals(600, $this->cart->fresh()->getTotal()); + + // Add 3: Spot 2 has no price and pool has no price, so next is Spot 3 (1000) + // Wait - Spot 2 should be skipped since it has no price and no pool fallback + // Expected: 300 + 300 + 1000 = 1600 + $this->cart->addToCart($pool, 1); + $this->assertEquals(1600, $this->cart->fresh()->getTotal()); + + // Add 4: Still Spot 3 (1000), cumulative 2600 + $this->cart->addToCart($pool, 1); + $this->assertEquals(2600, $this->cart->fresh()->getTotal()); + + // Add 5: No more available (Spot 1 has 2, Spot 3 has 2, Spot 2 has 0 available due to no price) + // Total available: 4, should throw exception on 5th + // Note: Throws HasNoPriceException because all PRICED items are exhausted + // (Spot 2 has stock but no price, so it's not available for sale) + $this->expectException(\Blax\Shop\Exceptions\HasNoPriceException::class); + $this->cart->addToCart($pool, 1); + } + + /** @test */ + public function config_b_cart_items_have_correct_price_ids() + { + $this->cart = $this->createCart(); + ['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: false, poolManagesStock: false); + + // Get price IDs for reference + $spot1PriceId = $spots[0]->defaultPrice()->first()->id; + $spot3PriceId = $spots[2]->defaultPrice()->first()->id; + + // Add 4 items (max available when Spot 2 has no price) + $this->cart->addToCart($pool, 4); + + $items = $this->cart->items()->orderBy('price', 'asc')->get(); + + // Items with price 300 should have Spot 1's price_id + $item300 = $items->first(fn($i) => $i->price === 300); + $this->assertNotNull($item300); + $this->assertEquals($spot1PriceId, $item300->price_id); + + // Items with price 1000 should have Spot 3's price_id + $item1000 = $items->first(fn($i) => $i->price === 1000); + $this->assertNotNull($item1000); + $this->assertEquals($spot3PriceId, $item1000->price_id); + } + + /** @test */ + public function config_b_set_dates_doubles_cart_total() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: false, poolManagesStock: false); + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days + + // Add 4 items (max available) + $this->cart->addToCart($pool, 4, [], $from, $until); + + // With 2 days: (300*2 + 300*2 + 1000*2 + 1000*2) = 5200 + $this->assertEquals(5200, $this->cart->fresh()->getTotal()); + } + + /** @test */ + public function config_b_set_dates_after_adding_recalculates_prices() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: false, poolManagesStock: false); + + // Add items without dates first + $this->cart->addToCart($pool, 4); + + // 1-day prices: 300 + 300 + 1000 + 1000 = 2600 + $this->assertEquals(2600, $this->cart->fresh()->getTotal()); + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days + + // Set dates - should recalculate to 2-day prices + $this->cart->setDates($from, $until, validateAvailability: false); + + // 2-day prices: 2600 * 2 = 5200 + $this->assertEquals(5200, $this->cart->fresh()->getTotal()); + } + + // ========================================== + // Configuration C: Pool HAS price, MANAGES stock + // ========================================== + + /** @test */ + public function config_c_progressive_pricing_step_by_step() + { + $this->cart = $this->createCart(); + ['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: true); + + // Add 1: Should use lowest price (300 from Spot 1) + $cartItem = $this->cart->addToCart($pool, 1); + $this->assertEquals(300, $this->cart->getTotal()); + $this->assertEquals(300, $cartItem->price); + + // Add 2: Still lowest price (300), cumulative 600 + $this->cart->addToCart($pool, 1); + $this->assertEquals(600, $this->cart->fresh()->getTotal()); + + // Add 3: Next lowest is pool price (500) for Spot 2, cumulative 1100 + $this->cart->addToCart($pool, 1); + $this->assertEquals(1100, $this->cart->fresh()->getTotal()); + + // Add 4: Pool price again (500), cumulative 1600 + $this->cart->addToCart($pool, 1); + $this->assertEquals(1600, $this->cart->fresh()->getTotal()); + + // Add 5: Spot 3 price (1000), cumulative 2600 + $this->cart->addToCart($pool, 1); + $this->assertEquals(2600, $this->cart->fresh()->getTotal()); + + // Add 6: Spot 3 price again (1000), cumulative 3600 + $this->cart->addToCart($pool, 1); + $this->assertEquals(3600, $this->cart->fresh()->getTotal()); + + // Add 7: Should throw exception - no more stock + $this->expectException(NotEnoughStockException::class); + $this->cart->addToCart($pool, 1); + } + + /** @test */ + public function config_c_cart_items_have_correct_price_ids() + { + $this->cart = $this->createCart(); + ['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: true); + + // Get price IDs for reference + $spot1PriceId = $spots[0]->defaultPrice()->first()->id; + $poolPriceId = $pool->defaultPrice()->first()->id; + $spot3PriceId = $spots[2]->defaultPrice()->first()->id; + + // Add 6 items + $this->cart->addToCart($pool, 6); + + $items = $this->cart->items()->orderBy('price', 'asc')->get(); + + // First cart item group (price 300) should have Spot 1's price_id + $item300 = $items->first(fn($i) => $i->price === 300); + $this->assertNotNull($item300); + $this->assertEquals($spot1PriceId, $item300->price_id); + + // Second cart item group (price 500) should have Pool's price_id (for Spot 2 fallback) + $item500 = $items->first(fn($i) => $i->price === 500); + $this->assertNotNull($item500); + $this->assertEquals($poolPriceId, $item500->price_id); + + // Third cart item group (price 1000) should have Spot 3's price_id + $item1000 = $items->first(fn($i) => $i->price === 1000); + $this->assertNotNull($item1000); + $this->assertEquals($spot3PriceId, $item1000->price_id); + } + + /** @test */ + public function config_c_set_dates_doubles_cart_total() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: true); + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days + + // Add items with dates + $this->cart->addToCart($pool, 6, [], $from, $until); + + // With 2 days: 300*2 + 300*2 + 500*2 + 500*2 + 1000*2 + 1000*2 = 7200 + $this->assertEquals(7200, $this->cart->fresh()->getTotal()); + } + + /** @test */ + public function config_c_set_dates_after_adding_recalculates_prices() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: true); + + // Add items without dates first + $this->cart->addToCart($pool, 6); + + // 1-day prices: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600 + $this->assertEquals(3600, $this->cart->fresh()->getTotal()); + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days + + // Set dates - should recalculate to 2-day prices + $this->cart->setDates($from, $until, validateAvailability: false); + + // 2-day prices: 7200 + $this->assertEquals(7200, $this->cart->fresh()->getTotal()); + } + + /** @test */ + public function config_c_pool_stock_is_ignored_when_single_items_manage_stock() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: true); + + // Pool manages stock but has no stock of its own + // Availability should still come from single items + $this->assertEquals(6, $pool->getAvailableQuantity()); + } + + // ========================================== + // Configuration D: Pool does NOT have price, MANAGES stock + // ========================================== + + /** @test */ + public function config_d_progressive_pricing_step_by_step() + { + $this->cart = $this->createCart(); + ['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: false, poolManagesStock: true); + + // Add 1: Should use lowest price (300 from Spot 1) + $cartItem = $this->cart->addToCart($pool, 1); + $this->assertEquals(300, $this->cart->getTotal()); + $this->assertEquals(300, $cartItem->price); + + // Add 2: Still lowest price (300), cumulative 600 + $this->cart->addToCart($pool, 1); + $this->assertEquals(600, $this->cart->fresh()->getTotal()); + + // Add 3: Spot 2 has no price and pool has no price, so next is Spot 3 (1000) + $this->cart->addToCart($pool, 1); + $this->assertEquals(1600, $this->cart->fresh()->getTotal()); + + // Add 4: Still Spot 3 (1000), cumulative 2600 + $this->cart->addToCart($pool, 1); + $this->assertEquals(2600, $this->cart->fresh()->getTotal()); + + // Add 5: Should throw exception - only 4 available (Spot 1:2 + Spot 3:2) + // Note: Throws HasNoPriceException because all PRICED items are exhausted + // (Spot 2 has stock but no price, so it's not available for sale) + $this->expectException(\Blax\Shop\Exceptions\HasNoPriceException::class); + $this->cart->addToCart($pool, 1); + } + + /** @test */ + public function config_d_cart_items_have_correct_price_ids() + { + $this->cart = $this->createCart(); + ['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: false, poolManagesStock: true); + + // Get price IDs for reference + $spot1PriceId = $spots[0]->defaultPrice()->first()->id; + $spot3PriceId = $spots[2]->defaultPrice()->first()->id; + + // Add 4 items (max available when Spot 2 has no price) + $this->cart->addToCart($pool, 4); + + $items = $this->cart->items()->orderBy('price', 'asc')->get(); + + // Items with price 300 should have Spot 1's price_id + $item300 = $items->first(fn($i) => $i->price === 300); + $this->assertNotNull($item300); + $this->assertEquals($spot1PriceId, $item300->price_id); + + // Items with price 1000 should have Spot 3's price_id + $item1000 = $items->first(fn($i) => $i->price === 1000); + $this->assertNotNull($item1000); + $this->assertEquals($spot3PriceId, $item1000->price_id); + } + + /** @test */ + public function config_d_set_dates_doubles_cart_total() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: false, poolManagesStock: true); + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days + + // Add 4 items (max available) + $this->cart->addToCart($pool, 4, [], $from, $until); + + // With 2 days: (300*2 + 300*2 + 1000*2 + 1000*2) = 5200 + $this->assertEquals(5200, $this->cart->fresh()->getTotal()); + } + + /** @test */ + public function config_d_set_dates_after_adding_recalculates_prices() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: false, poolManagesStock: true); + + // Add items without dates first + $this->cart->addToCart($pool, 4); + + // 1-day prices: 300 + 300 + 1000 + 1000 = 2600 + $this->assertEquals(2600, $this->cart->fresh()->getTotal()); + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days + + // Set dates - should recalculate to 2-day prices + $this->cart->setDates($from, $until, validateAvailability: false); + + // 2-day prices: 2600 * 2 = 5200 + $this->assertEquals(5200, $this->cart->fresh()->getTotal()); + } + + // ========================================== + // Additional tests for date management + // ========================================== + + /** @test */ + public function set_dates_validates_availability_for_each_cart_item() + { + $this->cart = $this->createCart(); + ['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false); + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(2)->startOfDay(); + + // Add items WITH dates (so they become booking items that get validated) + $from2 = Carbon::now()->addDays(5)->startOfDay(); + $until2 = Carbon::now()->addDays(6)->startOfDay(); + $this->cart->addToCart($pool, 5, [], $from2, $until2); + + // Claim ALL stock for the NEW period we're about to set + // This leaves 0 available for the new period + $spots[0]->claimStock(2, null, $from, $until); + $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); + $this->cart->setDates($from, $until, validateAvailability: true); + } + + /** @test */ + public function cart_item_subtotal_updates_when_dates_change() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false); + + // Add 2 items without dates (same price tier, should merge) + $this->cart->addToCart($pool, 2); + + $item = $this->cart->items()->first(); + $this->assertEquals(600, $item->subtotal); // 300 * 2 + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(4)->startOfDay(); // 3 days + + // Update dates via cart + $this->cart->setDates($from, $until, validateAvailability: false); + + $item->refresh(); + // Should be 300 * 3 days * 2 quantity = 1800 + $this->assertEquals(1800, $item->subtotal); + } + + /** @test */ + public function cart_total_and_item_subtotals_match() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false); + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days + + // Add 6 items + $this->cart->addToCart($pool, 6, [], $from, $until); + + // Calculate expected total from item subtotals + $expectedTotal = $this->cart->items()->sum('subtotal'); + + $this->assertEquals($expectedTotal, $this->cart->getTotal()); + $this->assertEquals(7200, $this->cart->getTotal()); + } + + /** @test */ + public function removing_items_updates_pool_availability() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false); + + // Add 6 items + $this->cart->addToCart($pool, 6); + $this->assertEquals(3600, $this->cart->getTotal()); + + // Remove 1 item (should remove from highest price first - LIFO) + $this->cart->removeFromCart($pool, 1); + + // Now we should be able to add 1 more + $this->cart->addToCart($pool, 1); + $this->assertEquals(3600, $this->cart->fresh()->getTotal()); + } + + /** @test */ + public function adding_quantity_greater_than_one_respects_availability() + { + $this->cart = $this->createCart(); + ['pool' => $pool] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false); + + $from = Carbon::now()->addDay()->startOfDay(); + $until = Carbon::now()->addDays(2)->startOfDay(); + + // Try to add 7 at once - should fail immediately + $this->expectException(NotEnoughStockException::class); + $this->cart->addToCart($pool, 7, [], $from, $until); + } + + /** @test */ + public function pool_with_all_single_items_without_prices_throws_exception() + { + $pool = Product::factory()->create([ + 'name' => 'No Price Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'No Price Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(2); + + $spot2 = Product::factory()->create([ + 'name' => 'No Price Spot 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot2->increaseStock(2); + + $pool->attachSingleItems([$spot1->id, $spot2->id]); + $pool->setPoolPricingStrategy('lowest'); + + $this->cart = $this->createCart(); + + $this->expectException(\Blax\Shop\Exceptions\HasNoPriceException::class); + $this->cart->addToCart($pool, 1); + } +} diff --git a/tests/Feature/PoolPerMinutePricingTest.php b/tests/Feature/PoolPerMinutePricingTest.php index 9ac3233..9da3a6b 100644 --- a/tests/Feature/PoolPerMinutePricingTest.php +++ b/tests/Feature/PoolPerMinutePricingTest.php @@ -135,7 +135,9 @@ class PoolPerMinutePricingTest extends TestCase /** @test */ public function it_uses_direct_pool_price_for_fractional_days() { - // Set direct price on pool instead of using inherited pricing + // Set direct price on pool - this is now used as fallback for single items without prices + // Since all single items in this test already have prices, the pool's direct price + // won't be used. The lowest single item price will be used instead. ProductPrice::factory()->create([ 'purchasable_id' => $this->poolProduct->id, 'purchasable_type' => Product::class, @@ -149,9 +151,11 @@ class PoolPerMinutePricingTest extends TestCase $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); - // Direct pool price is $20.00 (2000 cents), for 0.5 days = $10.00 (1000 cents) - $this->assertEquals(1000, $cartItem->price); - $this->assertEquals(1000, $cartItem->subtotal); + // Pool's direct price is now only a fallback for single items without prices. + // Since both single items have prices ($50 and $30), the lowest ($30) is used. + // $30.00 (3000 cents) for 0.5 days = $15.00 (1500 cents) + $this->assertEquals(1500, $cartItem->price); + $this->assertEquals(1500, $cartItem->subtotal); } /** @test */