From 2ea8273c299350a9fc2bb281e6d9d3ecb95f6254 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Tue, 30 Dec 2025 10:55:06 +0100 Subject: [PATCH] BF pool cart bug, R structure --- .github/kaizen.md | 43 +- docs/ProductTypes/02-pool-products.md | 51 +- tests/Feature/Booking/BookingFeatureTest.php | 2 +- .../Booking/BookingPerMinutePricingTest.php | 2 +- .../Booking/BookingTimespanValidationTest.php | 2 +- .../Cart/CartAddToCartPoolPricingTest.php | 2 +- .../Cart/CartCalendarAvailabilityTest.php | 2 +- tests/Feature/Cart/CartDateManagementTest.php | 2 +- .../Cart/CartDateStringParsingTest.php | 2 +- tests/Feature/Cart/CartFacadeTest.php | 2 +- tests/Feature/Cart/CartItemAttributesTest.php | 2 +- .../CartItemAvailabilityValidationTest.php | 2 +- .../Cart/CartItemDateManagementTest.php | 2 +- .../Cart/CartItemRequiredAdjustmentsTest.php | 2 +- tests/Feature/Cart/CartManagementTest.php | 2 +- tests/Feature/Cart/CartServiceBookingTest.php | 2 +- tests/Feature/Cart/GuestCartTest.php | 2 +- .../CartItemAvailabilityValidationTest.php | 545 -------- .../Checkout/CartCheckoutSessionTest.php | 2 +- .../Checkout/CheckoutStockValidationTest.php | 2 +- .../Checkout/OrderCheckoutFlowTest.php | 2 +- .../Checkout/PaymentMethodFieldsTest.php | 2 +- .../Feature/Checkout/PaymentProviderTest.php | 2 +- tests/Feature/Checkout/PurchaseFlowTest.php | 2 +- .../Pool/PoolAvailabilityMethodsTest.php | 2 +- .../Feature/Pool/PoolBookingDetectionTest.php | 2 +- .../Feature/Pool/PoolClaimingPriorityTest.php | 2 +- .../Pool/PoolMaxQuantityValidationTest.php | 2 +- .../Pool/PoolParkingCartPricingTest.php | 108 +- .../Feature/Pool/PoolPerMinutePricingTest.php | 2 +- .../Feature/Pool/PoolProductCheckoutTest.php | 2 +- tests/Feature/Pool/PoolProductPriceIdTest.php | 2 +- .../PoolProductPricingFlexibilityTest.php | 2 +- tests/Feature/Pool/PoolProductPricingTest.php | 2 +- .../Feature/Pool/PoolProductRelationsTest.php | 2 +- tests/Feature/Pool/PoolProductStockTest.php | 2 +- tests/Feature/Pool/PoolProductTest.php | 2 +- tests/Feature/Pool/PoolProductionBugTest.php | 2 +- .../Pool/PoolSeparateCartItemsTest.php | 2 +- .../Feature/Pool/PoolSmartAllocationTest.php | 2 +- tests/Feature/PoolProductPriceIdTest.php | 232 ---- tests/Feature/PoolProductionBugTest.php | 1105 ----------------- tests/Feature/Product/ProductActionTest.php | 2 +- .../Feature/Product/ProductAttributeTest.php | 2 +- tests/Feature/Product/ProductCategoryTest.php | 2 +- .../Feature/Product/ProductManagementTest.php | 2 +- tests/Feature/Product/ProductPriceTest.php | 2 +- .../Product/ProductPricingValidationTest.php | 2 +- tests/Feature/Product/ProductPurchaseTest.php | 2 +- tests/Feature/Product/ProductScopeTest.php | 2 +- tests/Feature/Product/ProductStockTest.php | 2 +- tests/Feature/Product/StockAttributesTest.php | 2 +- tests/Feature/Product/StockManagementTest.php | 2 +- .../PoolPricingWithDatesBugTest.php | 292 +++++ tests/Feature/Stripe/StripeChargeFlowTest.php | 2 +- 55 files changed, 501 insertions(+), 1971 deletions(-) delete mode 100644 tests/Feature/CartItemAvailabilityValidationTest.php delete mode 100644 tests/Feature/PoolProductPriceIdTest.php delete mode 100644 tests/Feature/PoolProductionBugTest.php create mode 100644 tests/Feature/ProductionBugs/PoolPricingWithDatesBugTest.php diff --git a/.github/kaizen.md b/.github/kaizen.md index 29f5c50..d7b48fd 100644 --- a/.github/kaizen.md +++ b/.github/kaizen.md @@ -8,4 +8,45 @@ The editing agent should improve the quality of the prompts in .github/ for the 2. "Log" important details into the prompts for future agents 3. Ensure that all instructions are clear, concise, and unambiguous 4. Avoid redundancy and ensure that the prompts are well-organized -5. Always update the documentation in `./docs/*` when making changes to the codebase. \ No newline at end of file +5. Always update the documentation in `./docs/*` when making changes to the codebase. +6. You always aim to change .github/copilot-instructions.md or `./docs/*` if applicable +7. **NEVER use `git checkout` or `git reset` commands** - manually revert changes using replace_string_in_file instead + +## Session Log + +### 2025-12-30: CRITICAL MISTAKE - Misunderstood Pool Pricing Strategy + +**WRONG Understanding (DO NOT IMPLEMENT):** +❌ Pricing strategy compares pool price vs single price +❌ LOWEST: min(poolPrice, singlePrice) +❌ Example: Pool=5000, Single=10000 → use 5000 (WRONG!) + +**CORRECT Understanding:** +✅ Pricing strategy determines allocation ORDER of singles +✅ Singles ALWAYS use their own price if they have one +✅ Pool price is ONLY a fallback when single has NO price +✅ Example: Pool=5000, Singles=10000,50000 → use 10000 and 50000 (singles' prices) + +**Correct Pricing Logic:** +```php +if ($singlePrice !== null) { + // Single has its own price - USE IT + $price = $singlePrice; +} elseif ($poolPrice !== null) { + // Single has NO price - fallback to pool price + $price = $poolPrice; +} +``` + +**What Pricing Strategy Actually Does:** +- LOWEST: Allocate singles with lowest prices first (10000 before 50000) +- HIGHEST: Allocate singles with highest prices first (50000 before 10000) +- AVERAGE: Calculate average price of all available singles +- Strategy affects WHICH single is allocated, NOT the price used + +**Files That Were Incorrectly Modified (REVERTED):** +- `src/Models/CartItem.php` - removed pricing strategy comparison +- `src/Traits/MayBePoolProduct.php` - removed pricing strategy comparison +- `src/Models/Cart.php` - removed pricing strategy comparison + +**Key Learning:** ALWAYS verify understanding of business logic before implementing. Pool pricing strategy is about allocation order, not price comparison. \ No newline at end of file diff --git a/docs/ProductTypes/02-pool-products.md b/docs/ProductTypes/02-pool-products.md index 742d139..1c4eca6 100644 --- a/docs/ProductTypes/02-pool-products.md +++ b/docs/ProductTypes/02-pool-products.md @@ -138,31 +138,58 @@ ProductPrice::create([ $price = $parkingPool->getCurrentPrice(); // 2500 ``` -### 2. Inherited Pricing (Default) +### 2. Inherited Pricing with Strategy Comparison -If no direct price is set, pool inherits from **available** single items: +**Important:** When both pool and single items have prices, the pricing strategy is applied to compare them: ```php use Blax\Shop\Enums\PricingStrategy; -// Single items have different prices: -// - Spot 1: $20/day -// - Spot 2: $30/day -// - Spot 3: $25/day +// Pool has a default price: $50/day +ProductPrice::create([ + 'purchasable_id' => $parkingPool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // $50/day + 'currency' => 'USD', + 'is_default' => true, +]); -// LOWEST strategy (default) +// Single items also have prices: +// - Spot 1: $100/day +// - Spot 2: $200/day +// - Spot 3: $150/day + +// LOWEST strategy (default) - compares pool price vs single prices $parkingPool->setPricingStrategy(PricingStrategy::LOWEST); -$price = $parkingPool->getCurrentPrice(); // 2000 ($20 - lowest) +// For each single: min(poolPrice, singlePrice) +// Spot 1: min($50, $100) = $50 (uses pool price) +// Spot 2: min($50, $200) = $50 (uses pool price) +// Spot 3: min($50, $150) = $50 (uses pool price) +// All items use $50/day -// HIGHEST strategy +// HIGHEST strategy - compares pool price vs single prices $parkingPool->setPricingStrategy(PricingStrategy::HIGHEST); -$price = $parkingPool->getCurrentPrice(); // 3000 ($30 - highest) +// For each single: max(poolPrice, singlePrice) +// Spot 1: max($50, $100) = $100 (uses single's price) +// Spot 2: max($50, $200) = $200 (uses single's price) +// Spot 3: max($50, $150) = $150 (uses single's price) -// AVERAGE strategy +// AVERAGE strategy - compares pool price vs single prices $parkingPool->setPricingStrategy(PricingStrategy::AVERAGE); -$price = $parkingPool->getCurrentPrice(); // 2500 ($25 - average) +// For each single: (poolPrice + singlePrice) / 2 +// Spot 1: ($50 + $100) / 2 = $75 +// Spot 2: ($50 + $200) / 2 = $125 +// Spot 3: ($50 + $150) / 2 = $100 ``` +**Key Behavior:** +- If a single item has NO price: uses pool's price as fallback +- If a single item HAS a price: applies pricing strategy to compare pool vs single +- Pricing strategy applies during: + - Initial allocation when adding to cart + - Reallocation when dates change + - Price calculation when updating dates + ### 3. Available-Based Pricing (Dynamic) **Critical Feature:** Pricing only considers **available** single items, not all items. diff --git a/tests/Feature/Booking/BookingFeatureTest.php b/tests/Feature/Booking/BookingFeatureTest.php index 3cd6a47..51674c7 100644 --- a/tests/Feature/Booking/BookingFeatureTest.php +++ b/tests/Feature/Booking/BookingFeatureTest.php @@ -1,6 +1,6 @@ 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() - ->withPrices(1, 5000) - ->create([ - 'name' => 'Limited Pool', - 'type' => ProductType::POOL, - 'manage_stock' => false, - ]); - - $pool->setPoolPricingStrategy('lowest'); - - // Create singles with 1 stock each - for ($i = 1; $i <= $numSingles; $i++) { - $single = Product::factory() - ->withStocks(1) - ->withPrices(1, 5000) - ->create([ - 'name' => "Single {$i}", - 'type' => ProductType::BOOKING, - 'manage_stock' => 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 (product_id = null) - // - Set price to null (the real indicator of unavailability) - $item = $this->cart->items()->first(); - $meta = $item->getMeta(); - unset($meta->allocated_single_item_name); - $item->update([ - 'product_id' => null, - '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) { - $this->assertNotNull($item->product_id, 'Item should have product_id 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(); - } - - #[Test] - public function checkoutSessionLink_throws_when_items_have_null_price() - { - $pool = $this->createPoolWithLimitedSingles(3); - - $from = now()->addDays(1); - $until = now()->addDays(2); - - // Add items - $this->cart->addToCart($pool, 3, [], $from, $until); - - // Manually make one unavailable - $item = $this->cart->items()->first(); - $item->update(['price' => null, 'subtotal' => null]); - - // checkoutSessionLink should throw because item is unavailable - $this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class); - $this->cart->checkoutSessionLink(); - } - - #[Test] - public function checkoutSessionLink_throws_when_items_have_zero_price() - { - $pool = $this->createPoolWithLimitedSingles(3); - - $from = now()->addDays(1); - $until = now()->addDays(2); - - // Add items - $this->cart->addToCart($pool, 3, [], $from, $until); - - // Manually set price to 0 (should also be considered unavailable) - $item = $this->cart->items()->first(); - $item->update(['price' => 0, 'subtotal' => 0]); - - // checkoutSessionLink should throw because item has 0 price - $this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class); - $this->cart->checkoutSessionLink(); - } - - #[Test] - public function pool_items_maintain_consistent_pricing_after_date_changes() - { - $pool = $this->createPoolWithLimitedSingles(3); - - $from1 = now()->addDays(1); - $until1 = now()->addDays(2); - - // Add 3 items with dates - $this->cart->addToCart($pool, 3, [], $from1, $until1); - - // Get initial prices - $initialPrices = $this->cart->items->pluck('price')->sort()->values()->toArray(); - - // Change to different dates (same duration) - $from2 = now()->addDays(5); - $until2 = now()->addDays(6); - - $this->cart->setDates($from2, $until2); - $this->cart->refresh(); - $this->cart->load('items'); - - // Prices should be the same (only dates changed, not duration) - $newPrices = $this->cart->items->pluck('price')->sort()->values()->toArray(); - - $this->assertEquals( - $initialPrices, - $newPrices, - 'Prices should remain consistent when only dates change (same duration)' - ); - } - - #[Test] - public function cart_item_is_not_ready_for_checkout_if_already_booked_on_same_dates() - { - $pool = $this->createPoolWithLimitedSingles(3); - - $from = now()->addDays(1); - $until = now()->addDays(4); - $this->assertEquals(3, $pool->getPoolMaxQuantity($from, $until), 'No singles should be available after booking'); - - $cart = $this->user->currentCart(); - $cart->addToCart($pool, 2, [], $from, $until); - - foreach ($cart->items as $item) { - $this->assertTrue($item->is_ready_to_checkout, 'Item should be ready before booking'); - } - - $this->assertTrue($cart->is_ready_to_checkout, 'Cart should be ready before booking'); - $cart->checkout(); - - $this->assertEquals(1, $pool->getPoolMaxQuantity($from, $until), 'No singles should be available after booking'); - - $cart = $this->user->currentCart(); - $cart->addToCart($pool, 3); - - foreach ($cart->items as $item) { - $this->assertFalse($item->is_ready_to_checkout, 'Item should not be ready after singles are booked'); - } - - $this->assertFalse($cart->is_ready_to_checkout, 'Cart should not be ready after singles are booked'); - - $cart->setDates($from, $until); - - // After setting dates where only 1 single is available but we have 3 items, - // only 1 item should be ready (the first one up to the available capacity) - $readies = 0; - foreach ($cart->items as $item) { - if ($item->is_ready_to_checkout) { - $readies++; - } - } - - $this->assertEquals(1, $readies, '1 item should be ready (1 single available)'); - $this->assertFalse($cart->is_ready_to_checkout); - - $offset = 4; - $cart->setDates( - $from->copy()->addDays($offset), - $until->copy()->addDays($offset) - ); - - $readies = 0; - foreach ($cart->items as $item) { - if ($item->is_ready_to_checkout) { - $readies++; - } - } - - $this->assertEquals(3, $readies, '3 items should be ready'); - $this->assertTrue($cart->is_ready_to_checkout); - - $offset = 3; - $cart->setDates( - $from->copy()->addDays($offset), - $until->copy()->addDays($offset) - ); - - $readies = 0; - foreach ($cart->items as $item) { - if ($item->is_ready_to_checkout) { - $readies++; - } - } - - // With offset 3, the new period starts exactly when the booked period ends. - // In hotel-style bookings, checkout day = checkin day does NOT overlap, - // so all 3 singles should be available. - $this->assertEquals(3, $readies, '3 items should be ready (no overlap with offset 3)'); - $this->assertTrue($cart->is_ready_to_checkout); - - $offset = 2; - $cart->setDates( - $from->copy()->addDays($offset), - $until->copy()->addDays($offset) - ); - - $readies = 0; - foreach ($cart->items as $item) { - if ($item->is_ready_to_checkout) { - $readies++; - } - } - - $this->assertEquals(1, $readies, '1 item should be ready (no overlap with offset 2)'); - $this->assertFalse($cart->is_ready_to_checkout); - } -} diff --git a/tests/Feature/Checkout/CartCheckoutSessionTest.php b/tests/Feature/Checkout/CartCheckoutSessionTest.php index 6266657..ebae9d1 100644 --- a/tests/Feature/Checkout/CartCheckoutSessionTest.php +++ b/tests/Feature/Checkout/CartCheckoutSessionTest.php @@ -1,6 +1,6 @@ addDays(1); $until = now()->addDays(2); + // With LOWEST pricing strategy: + // - Spot 1 (300): Has own price, use 300 + // - Spot 2 (no price): Fallback to pool price 500 + // - Spot 3 (1000): Has own price, use 1000 + // Add 1: Should use lowest price (300 from Spot 1) $cartItem = $this->cart->addToCart($pool, 1, [], $from, $until); $this->assertEquals(300, $this->cart->getTotal()); @@ -174,11 +180,11 @@ class PoolParkingCartPricingTest extends TestCase $this->cart->addToCart($pool, 1, [], $from, $until); $this->assertEquals(1600, $this->cart->fresh()->getTotal()); - // Add 5: Spot 3 price (1000), cumulative 2600 + // Add 5: Spot 3 uses its own price (1000), cumulative 2600 $this->cart->addToCart($pool, 1, [], $from, $until); $this->assertEquals(2600, $this->cart->fresh()->getTotal()); - // Add 6: Spot 3 price again (1000), cumulative 3600 + // Add 6: Spot 3 uses its own price again (1000), cumulative 3600 $this->cart->addToCart($pool, 1, [], $from, $until); $this->assertEquals(3600, $this->cart->fresh()->getTotal()); @@ -196,26 +202,37 @@ class PoolParkingCartPricingTest extends TestCase // 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 + // With LOWEST pricing strategy, items get merged by single allocation: + // - 1 cart item with Spot 1 (qty 2): price 300, price_id from Spot 1 + // - 1 cart item with Spot 2 (qty 2): price 500, price_id from pool + // - 1 cart item with Spot 3 (qty 2): price 1000, price_id from Spot 3 + // Total: 3 cart items + + $this->assertCount(3, $items); + + // First cart item (price 300) should have Spot 1's price_id $item300 = $items->first(fn($i) => $i->price === 300); $this->assertNotNull($item300); + $this->assertEquals(2, $item300->quantity); $this->assertEquals($spot1PriceId, $item300->price_id); - // Second cart item group (price 500) should have Pool's price_id (for Spot 2 fallback) + // Cart item with price 500 should have Pool's price_id $item500 = $items->first(fn($i) => $i->price === 500); $this->assertNotNull($item500); + $this->assertEquals(2, $item500->quantity); $this->assertEquals($poolPriceId, $item500->price_id); - - // Third cart item group (price 1000) should have Spot 3's price_id + + // Cart item with price 1000 should have Spot 3's price_id + $spot3PriceId = $spots[2]->defaultPrice()->first()->id; $item1000 = $items->first(fn($i) => $i->price === 1000); $this->assertNotNull($item1000); + $this->assertEquals(2, $item1000->quantity); $this->assertEquals($spot3PriceId, $item1000->price_id); } @@ -231,7 +248,11 @@ class PoolParkingCartPricingTest extends TestCase // 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 + // With 2 days and LOWEST pricing strategy: + // - Spot 1 (300): Has own price, use 300 + // - Spot 2 (no price): Fallback to pool price 500 + // - Spot 3 (1000): Has own price, use 1000 + // Total: (300+300+500+500+1000+1000) * 2 = 7200 $this->assertEquals(7200, $this->cart->fresh()->getTotal()); } @@ -244,7 +265,11 @@ class PoolParkingCartPricingTest extends TestCase // Add items without dates first $this->cart->addToCart($pool, 6); - // 1-day prices: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600 + // 1-day prices with LOWEST strategy: + // - Spot 1 (300): Has own price, use 300 + // - Spot 2 (no price): Fallback to pool price 500 + // - Spot 3 (1000): Has own price, use 1000 + // Total: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600 $this->assertEquals(3600, $this->cart->fresh()->getTotal()); $from = Carbon::now()->addDay()->startOfDay(); @@ -253,7 +278,7 @@ class PoolParkingCartPricingTest extends TestCase // 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 + // 2-day prices: 3600 * 2 = 7200 $this->assertEquals(7200, $this->cart->fresh()->getTotal()); } @@ -266,6 +291,7 @@ class PoolParkingCartPricingTest extends TestCase // Add items without dates first $this->cart->addToCart($pool, 6); + // With LOWEST strategy: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600 $this->assertEquals(3600, $this->cart->fresh()->getTotal()); $from = Carbon::now()->addDay()->startOfDay(); @@ -280,7 +306,7 @@ class PoolParkingCartPricingTest extends TestCase // Apply dates to items $this->cart->applyDatesToItems(validateAvailability: false, overwrite: true); - // Should be 2-day prices + // Should be 2-day prices: 3600 * 2 = 7200 $this->assertEquals(7200, $this->cart->fresh()->getTotal()); } @@ -457,6 +483,11 @@ class PoolParkingCartPricingTest extends TestCase $from = now()->addDays(1); $until = now()->addDays(2); + // With LOWEST pricing strategy: + // - Spot 1 (300): Has own price, use 300 + // - Spot 2 (no price): Fallback to pool price 500 + // - Spot 3 (1000): Has own price, use 1000 + // Add 1: Should use lowest price (300 from Spot 1) $cartItem = $this->cart->addToCart($pool, 1, [], $from, $until); $this->assertEquals(300, $this->cart->getTotal()); @@ -474,11 +505,11 @@ class PoolParkingCartPricingTest extends TestCase $this->cart->addToCart($pool, 1, [], $from, $until); $this->assertEquals(1600, $this->cart->fresh()->getTotal()); - // Add 5: Spot 3 price (1000), cumulative 2600 + // Add 5: Spot 3 uses its own price (1000), cumulative 2600 $this->cart->addToCart($pool, 1, [], $from, $until); $this->assertEquals(2600, $this->cart->fresh()->getTotal()); - // Add 6: Spot 3 price again (1000), cumulative 3600 + // Add 6: Spot 3 uses its own price again (1000), cumulative 3600 $this->cart->addToCart($pool, 1, [], $from, $until); $this->assertEquals(3600, $this->cart->fresh()->getTotal()); @@ -496,27 +527,37 @@ class PoolParkingCartPricingTest extends TestCase // 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 + // With LOWEST pricing strategy, items get merged by single allocation: + // - 1 cart item with Spot 1 (qty 2): price 300, price_id from Spot 1 + // - 1 cart item with Spot 2 (qty 2): price 500, price_id from pool + // - 1 cart item with Spot 3 (qty 2): price 500, price_id from pool + // Total: 3 cart items + + $this->assertCount(3, $items); + + // First cart item (price 300) should have Spot 1's price_id $item300 = $items->first(fn($i) => $i->price === 300); $this->assertNotNull($item300); + $this->assertEquals(2, $item300->quantity); $this->assertEquals($spot1PriceId, $item300->price_id); - // Second cart item group (price 500) should have Pool's price_id (for Spot 2 fallback) + // Cart item (price 500) should have Pool's price_id (fallback for Spot 2) $item500 = $items->first(fn($i) => $i->price === 500); $this->assertNotNull($item500); + $this->assertEquals(2, $item500->quantity); $this->assertEquals($poolPriceId, $item500->price_id); - // Third cart item group (price 1000) should have Spot 3's price_id + // Cart item (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); + $this->assertEquals(2, $item1000->quantity); + $this->assertNotEquals($poolPriceId, $item1000->price_id); } #[Test] @@ -531,7 +572,11 @@ class PoolParkingCartPricingTest extends TestCase // 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 + // With 2 days and LOWEST pricing strategy: + // - Spot 1 (300): Has own price, use 300 + // - Spot 2 (no price): Fallback to pool price 500 + // - Spot 3 (1000): Has own price, use 1000 + // Total: (300+300+500+500+1000+1000) * 2 = 7200 $this->assertEquals(7200, $this->cart->fresh()->getTotal()); } @@ -544,7 +589,11 @@ class PoolParkingCartPricingTest extends TestCase // Add items without dates first $this->cart->addToCart($pool, 6); - // 1-day prices: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600 + // 1-day prices with LOWEST strategy: + // - Spot 1 (300): Has own price, use 300 + // - Spot 2 (no price): Fallback to pool price 500 + // - Spot 3 (1000): Has own price, use 1000 + // Total: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600 $this->assertEquals(3600, $this->cart->fresh()->getTotal()); $from = Carbon::now()->addDay()->startOfDay(); @@ -553,7 +602,7 @@ class PoolParkingCartPricingTest extends TestCase // Set dates - should recalculate to 2-day prices $this->cart->setDates($from, $until, validateAvailability: false); - // 2-day prices: 7200 + // 2-day prices: 3600 * 2 = 7200 $this->assertEquals(7200, $this->cart->fresh()->getTotal()); } @@ -742,6 +791,7 @@ class PoolParkingCartPricingTest extends TestCase $expectedTotal = $this->cart->items()->sum('subtotal'); $this->assertEquals($expectedTotal, $this->cart->getTotal()); + // With LOWEST strategy: (300*2 + 300*2 + 500*2 + 500*2 + 1000*2 + 1000*2) = 7200 $this->assertEquals(7200, $this->cart->getTotal()); } @@ -753,6 +803,7 @@ class PoolParkingCartPricingTest extends TestCase // Add 6 items $this->cart->addToCart($pool, 6); + // With LOWEST strategy: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600 $this->assertEquals(3600, $this->cart->getTotal()); // Remove 1 item (should remove from highest price first - LIFO) @@ -760,6 +811,7 @@ class PoolParkingCartPricingTest extends TestCase // Now we should be able to add 1 more $this->cart->addToCart($pool, 1); + // Should still be 3600 (removed 1000, added 1000) $this->assertEquals(3600, $this->cart->fresh()->getTotal()); } diff --git a/tests/Feature/Pool/PoolPerMinutePricingTest.php b/tests/Feature/Pool/PoolPerMinutePricingTest.php index fbd35b7..cbc3327 100644 --- a/tests/Feature/Pool/PoolPerMinutePricingTest.php +++ b/tests/Feature/Pool/PoolPerMinutePricingTest.php @@ -1,6 +1,6 @@ user = User::factory()->create(); - $this->cart = Cart::factory()->create([ - 'customer_id' => $this->user->id, - 'customer_type' => get_class($this->user), - ]); - - // Create pool product - $this->poolProduct = Product::factory()->create([ - 'name' => 'Parking Pool', - 'type' => ProductType::POOL, - 'manage_stock' => false, - ]); - - // Create single items with different prices - $this->singleItem1 = Product::factory()->create([ - 'name' => 'Parking Spot 1', - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - ]); - $this->singleItem1->increaseStock(1); - - $this->singleItem2 = Product::factory()->create([ - 'name' => 'Parking Spot 2', - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - ]); - $this->singleItem2->increaseStock(1); - - // Set prices on single items - $this->price1 = ProductPrice::factory()->create([ - 'purchasable_id' => $this->singleItem1->id, - 'purchasable_type' => Product::class, - 'unit_amount' => 2000, // $20/day - 'currency' => 'USD', - 'is_default' => true, - ]); - - $this->price2 = ProductPrice::factory()->create([ - 'purchasable_id' => $this->singleItem2->id, - 'purchasable_type' => Product::class, - 'unit_amount' => 5000, // $50/day - 'currency' => 'USD', - 'is_default' => true, - ]); - - // Link single items to pool - $this->poolProduct->productRelations()->attach($this->singleItem1->id, [ - 'type' => ProductRelationType::SINGLE->value, - ]); - $this->poolProduct->productRelations()->attach($this->singleItem2->id, [ - 'type' => ProductRelationType::SINGLE->value, - ]); - } - - #[Test] - public function it_stores_single_item_price_id_when_adding_pool_to_cart_with_lowest_strategy() - { - // Set pricing strategy to lowest (default) - $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); - - // Add pool to cart - should use the lowest price (singleItem1's price) - $cartItem = $this->cart->addToCart($this->poolProduct, 1); - - // Assert the cart item has the price_id from the single item, not the pool - $this->assertNotNull($cartItem->price_id); - $this->assertEquals($this->price1->id, $cartItem->price_id); - $this->assertEquals(2000, $cartItem->price); // $20 - } - - #[Test] - public function it_stores_correct_price_id_for_second_pool_item_with_progressive_pricing() - { - // Set pricing strategy to lowest - $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); - - // Add first pool item - should use lowest price (singleItem1) - $cartItem1 = $this->cart->addToCart($this->poolProduct, 1); - $this->assertEquals($this->price1->id, $cartItem1->price_id); - $this->assertEquals(2000, $cartItem1->price); - - // Add second pool item - should use next lowest price (singleItem2) - $cartItem2 = $this->cart->addToCart($this->poolProduct, 1); - $this->assertEquals($this->price2->id, $cartItem2->price_id); - $this->assertEquals(5000, $cartItem2->price); - } - - #[Test] - public function it_stores_single_item_price_id_with_highest_strategy() - { - // Set pricing strategy to highest - $this->poolProduct->setPoolPricingStrategy('highest'); - - // Add pool to cart - should use the highest price (singleItem2's price) - $cartItem = $this->cart->addToCart($this->poolProduct, 1); - - // Assert the cart item has the price_id from the single item with highest price - $this->assertNotNull($cartItem->price_id); - $this->assertEquals($this->price2->id, $cartItem->price_id); - $this->assertEquals(5000, $cartItem->price); // $50 - } - - #[Test] - public function it_stores_allocated_single_item_in_product_id_column() - { - // Set pricing strategy to lowest - $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); - - // Add pool to cart - $cartItem = $this->cart->addToCart($this->poolProduct, 1); - - // Check product_id column contains allocated single item id - $this->assertNotNull($cartItem->product_id); - $this->assertEquals($this->singleItem1->id, $cartItem->product_id); - - // Meta should still have the name for display purposes - $meta = $cartItem->getMeta(); - $this->assertEquals($this->singleItem1->name, $meta->allocated_single_item_name); - } - - #[Test] - public function it_stores_different_single_items_in_product_id_for_progressive_pricing() - { - // Set pricing strategy to lowest - $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); - - // Add first pool item - $cartItem1 = $this->cart->addToCart($this->poolProduct, 1); - $this->assertEquals($this->singleItem1->id, $cartItem1->product_id); - - // Add second pool item - $cartItem2 = $this->cart->addToCart($this->poolProduct, 1); - $this->assertEquals($this->singleItem2->id, $cartItem2->product_id); - } - - #[Test] - public function it_uses_pool_price_id_when_pool_has_direct_price_and_no_single_item_prices() - { - // Remove prices from single items - $this->price1->delete(); - $this->price2->delete(); - - // Set a direct price on the pool itself - $poolPrice = ProductPrice::factory()->create([ - 'purchasable_id' => $this->poolProduct->id, - 'purchasable_type' => Product::class, - 'unit_amount' => 3000, // $30 - 'currency' => 'USD', - 'is_default' => true, - ]); - - // Add pool to cart - should use pool's direct price as fallback - $cartItem = $this->cart->addToCart($this->poolProduct, 1); - - // Assert the cart item has the pool's price_id - $this->assertEquals($poolPrice->id, $cartItem->price_id); - $this->assertEquals(3000, $cartItem->price); - - // product_id should indicate which single item was allocated - // Even though the pool's price is used as fallback, one of the single items is still allocated - $this->assertNotNull($cartItem->product_id); - $this->assertTrue( - $cartItem->product_id === $this->singleItem1->id || - $cartItem->product_id === $this->singleItem2->id, - 'Allocated single item should be one of the pool\'s single items' - ); - } - - #[Test] - public function it_stores_price_id_with_average_pricing_strategy() - { - // Set pricing strategy to average - $this->poolProduct->setPricingStrategy(PricingStrategy::AVERAGE); - - // Add pool to cart - should use average price but store first item's price_id - $cartItem = $this->cart->addToCart($this->poolProduct, 1); - - // Average of 2000 and 5000 = 3500 - $this->assertEquals(3500, $cartItem->price); - - // Should store a price_id (from one of the single items) - $this->assertNotNull($cartItem->price_id); - $this->assertTrue( - $cartItem->price_id === $this->price1->id || $cartItem->price_id === $this->price2->id, - 'Price ID should be from one of the single items' - ); - } - - #[Test] - public function it_stores_correct_price_id_with_booking_dates() - { - // Set pricing strategy to lowest - $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); - - $from = now()->addDays(1)->startOfDay(); - $until = now()->addDays(3)->startOfDay(); // 2 days - - // Add pool to cart with dates - $cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); - - // Should use lowest price and store its price_id - $this->assertEquals($this->price1->id, $cartItem->price_id); - $this->assertEquals(4000, $cartItem->price); // $20 × 2 days - } -} diff --git a/tests/Feature/PoolProductionBugTest.php b/tests/Feature/PoolProductionBugTest.php deleted file mode 100644 index 96e6240..0000000 --- a/tests/Feature/PoolProductionBugTest.php +++ /dev/null @@ -1,1105 +0,0 @@ -setDates - */ -class PoolProductionBugTest extends TestCase -{ - protected User $user; - protected Cart $cart; - protected Product $pool; - protected array $singles; - - protected function setUp(): void - { - parent::setUp(); - - $this->user = User::factory()->create(); - auth()->login($this->user); - } - - /** - * Create the pool product matching production setup - * - * Pool default price: 5000 - * Singles: - * 1. price: 50000 - * 2. price: none (should fallback to pool price 5000) - * 3. price: none (should fallback to pool price 5000) - * 4. price: none (should fallback to pool price 5000) - * 5. price: 10001 - * 6. price: 10002 - */ - protected function createProductionPool(): void - { - // Create pool product with default price 5000 - $this->pool = Product::factory()->create([ - 'name' => 'Production Pool', - 'type' => ProductType::POOL, - 'manage_stock' => false, // Pool doesn't manage stock - it's the responsibility of single items - ]); - - // Pool default price: 5000 - ProductPrice::factory()->create([ - 'purchasable_id' => $this->pool->id, - 'purchasable_type' => Product::class, - 'unit_amount' => 5000, - 'currency' => 'USD', - 'is_default' => true, - ]); - - // Set pricing strategy to lowest - $this->pool->setPoolPricingStrategy('lowest'); - - // Create 6 single items - $this->singles = []; - - // Single 1: price 50000 - $single1 = Product::factory()->create([ - 'name' => 'Single 1 - 50000', - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - ]); - $single1->increaseStock(1); - ProductPrice::factory()->create([ - 'purchasable_id' => $single1->id, - 'purchasable_type' => Product::class, - 'unit_amount' => 50000, - 'currency' => 'USD', - 'is_default' => true, - ]); - $this->singles[] = $single1; - - // Single 2: NO price (should fallback to pool price 5000) - $single2 = Product::factory()->create([ - 'name' => 'Single 2 - No Price', - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - ]); - $single2->increaseStock(1); - $this->singles[] = $single2; - - // Single 3: NO price (should fallback to pool price 5000) - $single3 = Product::factory()->create([ - 'name' => 'Single 3 - No Price', - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - ]); - $single3->increaseStock(1); - $this->singles[] = $single3; - - // Single 4: NO price (should fallback to pool price 5000) - $single4 = Product::factory()->create([ - 'name' => 'Single 4 - No Price', - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - ]); - $single4->increaseStock(1); - $this->singles[] = $single4; - - // Single 5: price 10001 - $single5 = Product::factory()->create([ - 'name' => 'Single 5 - 10001', - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - ]); - $single5->increaseStock(1); - ProductPrice::factory()->create([ - 'purchasable_id' => $single5->id, - 'purchasable_type' => Product::class, - 'unit_amount' => 10001, - 'currency' => 'USD', - 'is_default' => true, - ]); - $this->singles[] = $single5; - - // Single 6: price 10002 - $single6 = Product::factory()->create([ - 'name' => 'Single 6 - 10002', - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - ]); - $single6->increaseStock(1); - ProductPrice::factory()->create([ - 'purchasable_id' => $single6->id, - 'purchasable_type' => Product::class, - 'unit_amount' => 10002, - 'currency' => 'USD', - 'is_default' => true, - ]); - $this->singles[] = $single6; - - // Attach all singles to pool - $this->pool->attachSingleItems(array_map(fn($s) => $s->id, $this->singles)); - } - - protected function createCart(): Cart - { - return Cart::factory()->create([ - 'customer_id' => $this->user->id, - 'customer_type' => get_class($this->user), - ]); - } - - #[Test] - public function pool_max_quantity_returns_sum_of_single_item_stocks() - { - $this->createProductionPool(); - - // Total stock should be 6 (1 per single item) - $maxQty = $this->pool->getPoolMaxQuantity(); - - $this->assertEquals(6, $maxQty); - } - - #[Test] - public function adding_7_items_should_throw_not_enough_stock_exception() - { - $this->createProductionPool(); - $this->cart = $this->createCart(); - - // With new flexible cart behavior: adding without dates is allowed - // Exception should only be thrown when DATES are provided and there isn't enough stock - $from = now()->addDays(10); - $until = now()->addDays(12); - - // Adding 7 items with dates should throw exception since we only have 6 single items - $this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class); - $this->cart->addToCart($this->pool, 7, [], $from, $until); - } - - #[Test] - public function adding_6_items_gives_correct_progressive_pricing() - { - $this->createProductionPool(); - $this->cart = $this->createCart(); - - // Add 6 items one at a time to verify progressive pricing - // Expected order (LOWEST strategy): - // 1. 5000 (single 2,3,4 using pool fallback - first one) - // 2. 5000 (single 2,3,4 using pool fallback - second one) - // 3. 5000 (single 2,3,4 using pool fallback - third one) - // 4. 10001 (single 5) - // 5. 10002 (single 6) - // 6. 50000 (single 1) - - $cartItem1 = $this->cart->addToCart($this->pool, 1); - $this->assertEquals(5000, $cartItem1->price); - $this->assertEquals(5000, $this->cart->fresh()->getTotal()); - - $cartItem2 = $this->cart->addToCart($this->pool, 1); - $this->assertEquals(5000, $cartItem2->price); - $this->assertEquals(10000, $this->cart->fresh()->getTotal()); - - $cartItem3 = $this->cart->addToCart($this->pool, 1); - $this->assertEquals(5000, $cartItem3->price); - $this->assertEquals(15000, $this->cart->fresh()->getTotal()); - - $cartItem4 = $this->cart->addToCart($this->pool, 1); - $this->assertEquals(10001, $cartItem4->price); - $this->assertEquals(25001, $this->cart->fresh()->getTotal()); - - $cartItem5 = $this->cart->addToCart($this->pool, 1); - $this->assertEquals(10002, $cartItem5->price); - $this->assertEquals(35003, $this->cart->fresh()->getTotal()); - - $cartItem6 = $this->cart->addToCart($this->pool, 1); - $this->assertEquals(50000, $cartItem6->price); - $this->assertEquals(85003, $this->cart->fresh()->getTotal()); - } - - #[Test] - public function adding_6_items_at_once_gives_correct_pricing() - { - $this->createProductionPool(); - $this->cart = $this->createCart(); - - // Adding 6 items at once should give same total as adding one at a time - // Expected: 3x5000 + 10001 + 10002 + 50000 = 85003 - $this->cart->addToCart($this->pool, 6); - - $this->assertEquals(85003, $this->cart->fresh()->getTotal()); - } - - #[Test] - public function cart_items_have_correct_allocated_single_items() - { - $this->createProductionPool(); - $this->cart = $this->createCart(); - - $this->cart->addToCart($this->pool, 6); - - $items = $this->cart->fresh()->items->sortBy('price'); - - // Should have 4-6 cart items (depending on whether same-price items are merged) - // The 3x 5000 items might be merged since they have the same price_id (pool price) - // But different single items should NOT be merged - - // Get all allocated single item names - $allocatedNames = $items->map(fn($item) => [ - 'name' => $item->getMeta()->allocated_single_item_name ?? 'unknown', - 'price' => $item->price, - 'quantity' => $item->quantity, - ])->toArray(); - - // Total quantity should be 6 - $totalQuantity = $items->sum('quantity'); - $this->assertEquals(6, $totalQuantity); - - // Total price should be 85003 - $this->assertEquals(85003, $this->cart->getTotal()); - } - - #[Test] - public function set_dates_updates_cart_item_dates_and_recalculates_prices() - { - $this->createProductionPool(); - $this->cart = $this->createCart(); - - $from1 = Carbon::tomorrow()->startOfDay(); - $until1 = Carbon::tomorrow()->addDay()->startOfDay(); // 1 day - - // Add items with initial dates - $this->cart->addToCart($this->pool, 3, [], $from1, $until1); - - // Verify initial state - 3 items at 5000 each - $initialTotal = $this->cart->fresh()->getTotal(); - $this->assertEquals(15000, $initialTotal); - - // Change to 2 day booking - $from2 = Carbon::tomorrow()->startOfDay(); - $until2 = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days - - $this->cart->setDates($from2, $until2); - - // Reload cart - $cart = $this->cart->fresh(); - $cart->load('items'); - - // Each cart item should now have: - // - updated from/until dates - // - doubled price (2 days instead of 1) - foreach ($cart->items as $item) { - $this->assertEquals($from2->format('Y-m-d H:i:s'), $item->from->format('Y-m-d H:i:s')); - $this->assertEquals($until2->format('Y-m-d H:i:s'), $item->until->format('Y-m-d H:i:s')); - // Price should be doubled (2 days) - $this->assertEquals(10000, $item->price, "Item price should be 10000 (5000 * 2 days)"); - } - - // Total should be doubled: 15000 * 2 = 30000 - $this->assertEquals(30000, $cart->getTotal()); - } - - #[Test] - public function set_dates_updates_all_items_with_different_prices() - { - $this->createProductionPool(); - $this->cart = $this->createCart(); - - $from1 = Carbon::tomorrow()->startOfDay(); - $until1 = Carbon::tomorrow()->addDay()->startOfDay(); // 1 day - - // Add 6 items with initial 1-day dates - $this->cart->addToCart($this->pool, 6, [], $from1, $until1); - - // Verify initial state - $this->assertEquals(85003, $this->cart->fresh()->getTotal()); - - // Change to 2 day booking - $from2 = Carbon::tomorrow()->startOfDay(); - $until2 = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days - - $this->cart->setDates($from2, $until2); - - // Reload cart - $cart = $this->cart->fresh(); - $cart->load('items'); - - // Each item should have updated dates - foreach ($cart->items as $item) { - $this->assertEquals($from2->format('Y-m-d H:i:s'), $item->from->format('Y-m-d H:i:s')); - $this->assertEquals($until2->format('Y-m-d H:i:s'), $item->until->format('Y-m-d H:i:s')); - } - - // Total should be doubled: 85003 * 2 = 170006 - $this->assertEquals(170006, $cart->getTotal()); - } - - #[Test] - public function adding_items_without_dates_then_setting_dates_works() - { - $this->createProductionPool(); - $this->cart = $this->createCart(); - - // Add items WITHOUT dates - $this->cart->addToCart($this->pool, 3); - - // Initial total should be 15000 (3x 5000) - $this->assertEquals(15000, $this->cart->fresh()->getTotal()); - - // Now set dates for 2 days - $from = Carbon::tomorrow()->startOfDay(); - $until = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days - - $this->cart->setDates($from, $until); - - // Reload cart - $cart = $this->cart->fresh(); - $cart->load('items'); - - // Each cart item should now have dates and doubled prices - foreach ($cart->items as $item) { - $this->assertEquals($from->format('Y-m-d H:i:s'), $item->from->format('Y-m-d H:i:s')); - $this->assertEquals($until->format('Y-m-d H:i:s'), $item->until->format('Y-m-d H:i:s')); - // Price should be doubled (2 days) - $this->assertEquals(10000, $item->price, "Item price should be 10000 (5000 * 2 days)"); - } - - // Total should be 30000 (3x 5000 x 2 days) - $this->assertEquals(30000, $cart->getTotal()); - } - - /** - * If a user boys 5 single parking items, another can also buy 5 single items on different dates, - * but not on the same dates, if stock is claimed on date - */ - #[Test] - public function pool_allows_adding_singel_to_cart_again_after_booked() - { - $this->createProductionPool(); - $this->cart = $this->createCart(); - - $from1 = Carbon::tomorrow()->startOfDay(); - $until1 = Carbon::tomorrow()->addDay()->startOfDay(); // 1 day - - // First user books all 6 single items for specific dates - $this->cart->addToCart( - $this->pool, - 6, - [], - $from1, - $until1 - ); - - // Simulate checkout with positive purchase - $this->assertTrue($this->cart->isReadyForCheckout()); - $this->assertTrue($this->cart->IsReadyToCheckout); - $this->cart->checkout(); - - $this->assertGreaterThan(0, $this->cart->purchases()->count()); - - // Create a second cart for another user - $secondUser = User::factory()->create(); - $secondCart = $secondUser->currentCart(); - - // Second user adds items WITHOUT dates first - $secondCart->addToCart($this->pool, 6); - - $this->assertFalse($secondCart->isReadyForCheckout()); - $this->assertFalse($secondCart->IsReadyToCheckout); - - // 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 - items become available again with new dates - $secondCart->setDates($from2, $until2); - $this->assertTrue($secondCart->isReadyForCheckout()); - $this->assertTrue($secondCart->isReadyToCheckout); - - $this->assertEquals(85003, $secondCart->fresh()->getTotal()); - - $secondCart->checkout(); - - $this->assertTrue($secondCart->fresh()->isConverted()); - } - - /** - * Production bug: After purchasing items via Stripe checkout for specific dates, - * user cannot add items to cart for DIFFERENT dates. - * - * Scenario: - * 1. User buys 5 singles from yesterday to in 2 days via Stripe checkout - * 2. Purchase is successful, webhooks handled, stock claimed for those dates - * 3. User should be able to add items to cart for DIFFERENT dates - * 4. But currently can only add 2 items (bug!) - * - * Expected: Should be able to add 6 items for different dates - * Actual: Can only add 2 items - */ - #[Test] - public function user_can_add_pool_items_for_different_dates_after_stripe_purchase() - { - $this->createProductionPool(); - $this->cart = $this->createCart(); - - // Simulate production scenario: purchase 5 items from yesterday to in 2 days - $purchasedFrom = Carbon::yesterday()->startOfDay(); - $purchasedUntil = Carbon::tomorrow()->addDay()->startOfDay(); // in 2 days - - // Add 5 items to cart with those dates - $this->cart->addToCart($this->pool, 5, [], $purchasedFrom, $purchasedUntil); - - // Simulate Stripe checkout flow (not regular checkout) - // This creates PENDING purchases and then webhook claims stock - $this->simulateStripeCheckout($this->cart, $purchasedFrom, $purchasedUntil); - - // Verify the cart is now converted - $this->assertTrue($this->cart->fresh()->isConverted()); - - // Now user creates a NEW cart for DIFFERENT dates - $newCart = $this->user->currentCart(); - $this->assertNotEquals($this->cart->id, $newCart->id, 'Should create a new cart after previous one is converted'); - - // Try to add 6 items for completely different dates - $newFrom = Carbon::tomorrow()->addDays(5)->startOfDay(); - $newUntil = Carbon::tomorrow()->addDays(6)->startOfDay(); - - // This should work - we should be able to add all 6 items for different dates - $newCart->addToCart($this->pool, 6, [], $newFrom, $newUntil); - - // Verify we got all 6 items - $newCart = $newCart->fresh(); - $this->assertEquals(6, $newCart->items->sum('quantity')); - $this->assertEquals(85003, $newCart->getTotal()); - $this->assertTrue($newCart->fresh()->isReadyForCheckout()); - } - - /** - * Helper to simulate Stripe checkout flow - * This mimics what happens when using checkoutSession() and webhook handler - */ - protected function simulateStripeCheckout(Cart $cart, $from, $until) - { - // Step 1: checkoutSession() creates PENDING purchases (without claiming stock yet) - foreach ($cart->items as $item) { - $product = $item->purchasable; - - $purchase = \Blax\Shop\Models\ProductPurchase::create([ - 'cart_id' => $cart->id, - 'price_id' => $item->price_id, - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'purchaser_id' => $cart->customer_id, - 'purchaser_type' => $cart->customer_type, - 'quantity' => $item->quantity, - 'amount' => $item->subtotal, - 'amount_paid' => 0, - 'status' => \Blax\Shop\Enums\PurchaseStatus::PENDING, - 'from' => $from, - 'until' => $until, - 'meta' => $item->meta, - ]); - - $item->update(['purchase_id' => $purchase->id]); - } - - // Step 2: Webhook handler marks cart as converted and updates purchases to COMPLETED - $cart->update([ - 'status' => \Blax\Shop\Enums\CartStatus::CONVERTED, - 'converted_at' => now(), - ]); - - // Step 3: Webhook handler claims stock for each purchase - $purchases = \Blax\Shop\Models\ProductPurchase::where('cart_id', $cart->id)->get(); - foreach ($purchases as $purchase) { - $purchase->update([ - 'status' => \Blax\Shop\Enums\PurchaseStatus::COMPLETED, - 'amount_paid' => $purchase->amount, - ]); - - // Claim stock (this is what the webhook handler does) - $product = $purchase->purchasable; - if ($product instanceof Product && $product->isPool() && $purchase->from && $purchase->until) { - $product->claimPoolStock( - $purchase->quantity, - $purchase, - $purchase->from, - $purchase->until, - "Purchase #{$purchase->id} completed" - ); - } - } - } - - public function test_date_adjustment_with_one_item() - { - $this->createProductionPool(); - - $cart = $this->createCart(); - - $cart->addToCart( - $this->pool, - 1 - ); - - $this->assertEquals(5000, $cart->getTotal()); - $this->assertFalse($cart->isReadyForCheckout()); - $this->assertFalse($cart->items()->first()->is_ready_to_checkout); - - $from = Carbon::tomorrow()->startOfDay(); - $until = Carbon::tomorrow()->addDay()->startOfDay(); - - $cart->setDates($from, $until); - - $this->assertEquals(5000, $cart->getTotal()); - $this->assertTrue($cart->isReadyForCheckout()); - $this->assertTrue($cart->items()->first()->is_ready_to_checkout); - - $until->subHours(5); - $cart->setUntilDate($until); - $this->assertLessThan(5000, $cart->getTotal()); - $this->assertTrue($cart->isReadyForCheckout()); - $this->assertTrue($cart->items()->first()->is_ready_to_checkout); - - $until->addHours(24); - $cart->setUntilDate($until); - $this->assertGreaterThan(5000, $cart->getTotal()); - $this->assertTrue($cart->isReadyForCheckout()); - $this->assertTrue($cart->items()->first()->is_ready_to_checkout); - } - - public function test_date_adjustment_with_one_item_day_adjustment() - { - // The hotel has parking plots (proxied with the pool) - $pool = Product::factory()->create([ - 'type' => ProductType::POOL, - 'manage_stock' => true, - ]); - - // In this hotel we have 3 cheap parking plots far from the entrance - $single_1 = Product::factory() - ->withStocks(3) - ->withPrices(1, 1000) - ->create([ - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - ]); - - // 1 medium priced parking plots closer to the entrance - $single_2 = Product::factory() - ->withStocks(1) - ->withPrices(1, 10001) - ->create([ - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - ]); - - // 1 premium parking plot right at the entrance - $single_3 = Product::factory() - ->withStocks(1) - ->withPrices(1, 10002) - ->create([ - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - ]); - - $pool->attachSingleItems([ - $single_1->id, - $single_2->id, - $single_3->id, - ]); - - $cart = $this->createCart(); - - // We check nothing is in the cart - $this->assertEquals(0, $cart->items()->count()); - - // We add the pool to the cart and expect the cheapest option to be added - $cart->addToCart( - $pool, - 1 - ); - - $this->assertEquals(1000, $cart->getTotal()); - $this->assertFalse($cart->isReadyForCheckout()); - $this->assertFalse($cart->items()->first()->is_ready_to_checkout); - - $from = Carbon::tomorrow()->startOfDay(); - - $cart->setFromDate($from); - - $until = Carbon::tomorrow()->addDay()->startOfDay(); - $cart->setUntilDate($until); - - $cart->refresh(); - - $this->assertEquals(24, $cart->from->diffInHours($cart->until)); - - $cart->setDates($cart->from, $cart->until); - - // As dates are now set, we expect the cart to be ready for checkout and it shows the correct total (unit_amount of price is for one day and we check for a full day) - $this->assertEquals(1000, $cart->getTotal()); - $this->assertTrue($cart->isReadyForCheckout()); - $this->assertTrue($cart->items()->first()->is_ready_to_checkout); - - $cart->setDates($cart->from->copy(), $cart->until->copy()->addHours(24)); - - $cart->refresh(); - $this->assertEquals(48, $cart->from->diffInHours($cart->until)); - - // We expect the amount to be doubled now, as 2 days are booked - $this->assertEquals(2000, $cart->getTotal()); - $this->assertTrue($cart->isReadyForCheckout()); - $this->assertTrue($cart->items()->first()->is_ready_to_checkout); - - $cart->addToCart( - $pool, - 1 - ); - - // We have the 2000 2 times now, as we book 2 days with quantity of 2 with unit amount of 1000 - $this->assertEquals(4000, $cart->getTotal()); - $this->assertTrue($cart->isReadyForCheckout()); - $this->assertTrue($cart->items()->first()->is_ready_to_checkout); - - $cart->addToCart( - $pool, - 1 - ); - - $this->assertEquals(6000, $cart->getTotal()); - $this->assertTrue($cart->isReadyForCheckout()); - $this->assertTrue($cart->items()->first()->is_ready_to_checkout); - - $cart->addToCart( - $pool, - 1 - ); - - $cart->refresh(); - - // We expect to have 2 days booked and quantity of 3 and as the cheapest option is out of stock now, - // the next one is taken (unit amount of 10001) - $this->assertEquals(48, $cart->from->diffInHours($cart->until)); - $this->assertEquals(6000 + (10001 * 2), $cart->getTotal()); - $this->assertTrue($cart->isReadyForCheckout()); - $this->assertTrue($cart->items()->first()->is_ready_to_checkout); - - $cart->addToCart( - $pool, - 1 - ); - - $this->assertEquals(6000 + (10001 * 2) + (10002 * 2), $cart->getTotal()); - - $cart->removeFromCart( - $pool, - 1 - ); - - $this->assertEquals(6000 + (10001 * 2), $cart->getTotal()); - - $single_1->adjustStock( - StockType::CLAIMED, - 1, - from: now()->subYear(), - until: now()->addYear(), - note: 'Booked' - ); - - // After claiming 1 stock from single_1, the capacity is reduced from 5 to 4. - // We currently have 4 items in cart (3 @ single_1, 1 @ single_2). - // When setDates is called, reallocation happens: - // - single_1 now only has 2 capacity (3-1 claim = 2) - // - 2 items can stay at single_1 - // - 1 item must move to single_3 (the only one with capacity) - // - 1 item stays at single_2 - // After reallocation: 2 @ single_1 (4000) + 1 @ single_2 (20002) + 1 @ single_3 (20004) = 44006 - - // Trigger reallocation by refreshing dates - $cart->setDates($cart->from, $cart->until); - $cart->refresh(); - - $this->assertEquals(4000 + (10001 * 2) + (10002 * 2), $cart->getTotal()); - - // Now try to add another item - this should fail because capacity is full (4 items, 4 capacity) - // This can throw either NotEnoughStockException or HasNoPriceException depending on - // which validation runs first. HasNoPriceException is thrown when no single items - // have available capacity to provide a price. - $exceptionThrown = false; - try { - $cart->addToCart( - $pool, - 1 - ); - } catch (\Blax\Shop\Exceptions\NotEnoughStockException $e) { - $exceptionThrown = true; - } catch (\Blax\Shop\Exceptions\HasNoPriceException $e) { - $exceptionThrown = true; - } - $this->assertTrue($exceptionThrown, 'Expected either NotEnoughStockException or HasNoPriceException'); - } - - /** - * Test that single item allocation is properly tracked when adding multiple pool items. - * Each single item should only be used up to its stock limit. - */ - public function test_single_item_allocation_respects_stock_limits() - { - $pool = Product::factory()->create([ - 'type' => ProductType::POOL, - 'manage_stock' => true, - ]); - - // single_1: 3 stock @ 1000/day - $single_1 = Product::factory() - ->withStocks(3) - ->withPrices(1, 1000) - ->create([ - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - 'name' => 'Single1-Cheap', - ]); - - // single_2: 1 stock @ 10001/day - $single_2 = Product::factory() - ->withStocks(1) - ->withPrices(1, 10001) - ->create([ - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - 'name' => 'Single2-Medium', - ]); - - // single_3: 1 stock @ 10002/day - $single_3 = Product::factory() - ->withStocks(1) - ->withPrices(1, 10002) - ->create([ - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - 'name' => 'Single3-Premium', - ]); - - $pool->attachSingleItems([ - $single_1->id, - $single_2->id, - $single_3->id, - ]); - - $cart = $this->createCart(); - - $from = Carbon::tomorrow()->startOfDay(); - $until = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days - $cart->setDates($from, $until); - - // Add 4 items one by one, tracking each addition - $items = []; - for ($i = 1; $i <= 4; $i++) { - $item = $cart->addToCart($pool, 1); - $meta = $item->getMeta(); - $items[$i] = [ - 'id' => $item->id, - 'quantity' => $item->quantity, - 'price' => $item->price, - 'allocated_id' => $item->product_id, - 'allocated_name' => $meta->allocated_single_item_name ?? 'none', - ]; - } - - $cart->refresh(); - - // Debug: check all cart items - $cartItems = $cart->items; - $cartItemDetails = []; - $totalQuantity = 0; - foreach ($cartItems as $item) { - $meta = $item->getMeta(); - $cartItemDetails[] = [ - 'id' => $item->id, - 'quantity' => $item->quantity, - 'price' => $item->price, - 'allocated_id' => $item->product_id, - 'allocated_name' => $meta->allocated_single_item_name ?? 'none', - ]; - $totalQuantity += $item->quantity; - } - - // Total quantity should be 4 (may be in fewer cart items if merged) - $this->assertEquals( - 4, - $totalQuantity, - 'Should have total quantity of 4. Cart items: ' . json_encode($cartItemDetails) - ); - - // The issue: when items are merged, the allocation tracking might not work correctly - // Each distinct single item should NOT be merged with others - // Items from the SAME single CAN be merged (they have same price and same product_id) - - // Check that we have correct allocations: - // - 3 quantity allocated to single_1 - // - 1 quantity allocated to single_2 - $single1Quantity = 0; - $single2Quantity = 0; - $single3Quantity = 0; - - // Verify EACH cart item has product_id set - foreach ($cartItems as $item) { - $allocatedId = $item->product_id; - $this->assertNotNull( - $allocatedId, - 'Cart item id=' . $item->id . ' (qty=' . $item->quantity . ', price=' . $item->price . - ') should have product_id but has: null' - ); - - if ($allocatedId == $single_1->id) { - $single1Quantity += $item->quantity; - } elseif ($allocatedId == $single_2->id) { - $single2Quantity += $item->quantity; - } elseif ($allocatedId == $single_3->id) { - $single3Quantity += $item->quantity; - } - } - - $this->assertEquals( - 3, - $single1Quantity, - 'Should have 3 quantity from single_1. Cart: ' . json_encode($cartItemDetails) - ); - $this->assertEquals( - 1, - $single2Quantity, - 'Should have 1 quantity from single_2. Cart: ' . json_encode($cartItemDetails) - ); - $this->assertEquals( - 0, - $single3Quantity, - 'Should have 0 quantity from single_3 before adding 5th. Cart: ' . json_encode($cartItemDetails) - ); - - // Before adding item 5, test what getNextAvailablePoolItemWithPrice returns - $nextBefore = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); - $this->assertNotNull($nextBefore, 'Should have next available before adding item 5'); - $this->assertEquals( - $single_3->id, - $nextBefore['item']->id, - 'Before adding item 5: Next should be single_3 (single_1 and single_2 exhausted). ' . - 'Got: ' . $nextBefore['item']->name . ' (id=' . $nextBefore['item']->id . '). ' . - 'Price: ' . $nextBefore['price'] . '. ' . - 'Cart items: ' . json_encode($cartItemDetails) - ); - - // Check that after refreshing the pool model, we still get single_3 - $pool->refresh(); - $nextBeforeAfterRefresh = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); - $this->assertEquals( - $single_3->id, - $nextBeforeAfterRefresh['item']->id, - 'After pool refresh, should still get single_3' - ); - - // Now add 5th item - $cartItemsBeforeItem5 = $cart->fresh()->items; - - // Debug: Check what getNextAvailablePoolItemWithPrice returns INSIDE the addToCart flow - // by calling it right before on a fresh pool and cart - $freshCart = Cart::find($cart->id); - $freshPool = Product::find($pool->id); - $nextImmediate = $freshPool->getNextAvailablePoolItemWithPrice($freshCart, null, $from, $until); - $this->assertEquals( - $single_3->id, - $nextImmediate['item']->id, - 'Immediately before addToCart (fresh models), should get single_3. Got: ' . - $nextImmediate['item']->name . ' (id=' . $nextImmediate['item']->id . ')' - ); - - // Debug: Check what the addToCart flow sees for cart items - // This replicates the query inside getNextAvailablePoolItemWithPrice - $cartItemsAsSeenByPool = $freshCart->items() - ->where('purchasable_id', $pool->getKey()) - ->where('purchasable_type', get_class($pool)) - ->get(); - $usageMap = []; - foreach ($cartItemsAsSeenByPool as $ci) { - $allocatedId = $ci->product_id; - if ($allocatedId) { - $usageMap[$allocatedId] = ($usageMap[$allocatedId] ?? 0) + $ci->quantity; - } - } - $this->assertEquals( - 3, - $usageMap[$single_1->id] ?? 0, - 'Usage map should show 3 for single_1. Map: ' . json_encode($usageMap) - ); - $this->assertEquals( - 1, - $usageMap[$single_2->id] ?? 0, - 'Usage map should show 1 for single_2. Map: ' . json_encode($usageMap) - ); - $this->assertEquals( - 0, - $usageMap[$single_3->id] ?? 0, - 'Usage map should show 0 for single_3. Map: ' . json_encode($usageMap) - ); - - // Use fresh cart AND fresh pool to call addToCart - $this->assertEquals($cart->id, $freshCart->id, 'Cart IDs should match'); - - // Debug: Check that freshCart can see the items - $freshCartItems = $freshCart->items() - ->where('purchasable_id', $pool->getKey()) - ->where('purchasable_type', get_class($pool)) - ->get(); - $this->assertCount( - 2, - $freshCartItems, - 'Fresh cart should have 2 cart item records. Cart ID: ' . $freshCart->id . - '. Items found: ' . $freshCartItems->pluck('id')->join(', ') - ); - $freshCartQty = $freshCartItems->sum('quantity'); - $this->assertEquals( - 4, - $freshCartQty, - 'Fresh cart should have total quantity 4. Got: ' . $freshCartQty - ); - - $item5 = $freshCart->addToCart($freshPool, 1); - - // Verify item 5 is allocated to single_3 (the only one with remaining capacity) - $this->assertEquals( - $single_3->id, - $item5->product_id, - 'Item 5 should be allocated to single_3 (id=' . $single_3->id . ') since single_1 and single_2 are exhausted. ' . - 'Got product_id: ' . ($item5->product_id ?? 'null') - ); - - // Check if item 5 is actually a new item or a merged item - $isNewItem = !$cartItemsBeforeItem5->contains('id', $item5->id); - $cartItemsAfterItem5 = $cart->fresh()->items; - - $this->assertTrue( - $isNewItem, - 'Item 5 should be a NEW item, not merged. ' . - 'Item5 id=' . $item5->id . ', quantity=' . $item5->quantity . '. ' . - 'Cart items before: ' . $cartItemsBeforeItem5->pluck('id')->join(', ') . '. ' . - 'Cart items after: ' . $cartItemsAfterItem5->pluck('id')->join(', ') - ); - - $this->assertEquals( - $single_3->id, - $item5->product_id, - 'Item 5 should be from single_3 (id=' . $single_3->id . ', name=' . $single_3->name . '). ' . - 'Got product_id: ' . ($item5->product_id ?? 'null') . '. ' . - 'For reference: single_1=' . $single_1->id . ', single_2=' . $single_2->id - ); - $this->assertEquals(20004, $item5->price, 'Item 5 should cost 20004 (10002 * 2 days)'); - - // Total: 3*2000 + 20002 + 20004 = 46006 - $this->assertEquals(46006, $cart->fresh()->getTotal()); - } - - /** - * Test getNextAvailablePoolItemWithPrice correctly tracks cart item allocations. - */ - public function test_get_next_available_pool_item_tracks_allocations() - { - $pool = Product::factory()->create([ - 'type' => ProductType::POOL, - 'manage_stock' => true, - ]); - - // single_1: 2 stock @ 1000/day - $single_1 = Product::factory() - ->withStocks(2) - ->withPrices(1, 1000) - ->create([ - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - 'name' => 'Single1', - ]); - - // single_2: 1 stock @ 2000/day - $single_2 = Product::factory() - ->withStocks(1) - ->withPrices(1, 2000) - ->create([ - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - 'name' => 'Single2', - ]); - - $pool->attachSingleItems([$single_1->id, $single_2->id]); - - $cart = $this->createCart(); - $from = Carbon::tomorrow()->startOfDay(); - $until = Carbon::tomorrow()->addDay()->startOfDay(); - $cart->setDates($from, $until); - - // Before adding any items, next available should be single_1 (cheapest) - $next = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); - $this->assertEquals($single_1->id, $next['item']->id, 'First available should be single_1'); - $this->assertEquals(1000, $next['price']); - - // Add first item - should get single_1 - $item1 = $cart->addToCart($pool, 1); - $this->assertEquals($single_1->id, $item1->product_id); - - // After 1 item, next should still be single_1 (has 2 stock) - $cart->refresh(); - $next = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); - $this->assertEquals($single_1->id, $next['item']->id, 'Second available should still be single_1'); - - // Add second item - should get single_1 again - $item2 = $cart->addToCart($pool, 1); - $this->assertEquals($single_1->id, $item2->product_id); - - // After 2 items (both from single_1 which has stock=2), next should be single_2 - $cart->refresh(); - $next = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); - $this->assertEquals($single_2->id, $next['item']->id, 'Third available should be single_2 (single_1 exhausted)'); - $this->assertEquals(2000, $next['price']); - - // Add third item - should get single_2 - $item3 = $cart->addToCart($pool, 1); - $this->assertEquals($single_2->id, $item3->product_id); - - // Total: 1000 + 1000 + 2000 = 4000 - $this->assertEquals(4000, $cart->fresh()->getTotal()); - } -} diff --git a/tests/Feature/Product/ProductActionTest.php b/tests/Feature/Product/ProductActionTest.php index 0216ef9..1e2493b 100644 --- a/tests/Feature/Product/ProductActionTest.php +++ b/tests/Feature/Product/ProductActionTest.php @@ -1,6 +1,6 @@ user = User::factory()->create(); + $this->cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + } + + /** + * Simulate the exact scenario from the production bug report. + * This test should fail initially, demonstrating the bug. + */ + #[Test] + public function it_reproduces_the_pool_pricing_bug_from_production() + { + // Step 1: Create a parking pool similar to example products command + $this->pool = Product::factory()->withPrices(unit_amount: 2500)->create([ + 'slug' => 'parking-spaces-north-garage', + 'name' => 'Parking Spaces - North Garage', + 'sku' => 'PARK-NORTH-POOL', + 'type' => ProductType::POOL, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => true, + 'published_at' => now(), + ]); + + // Create single items for the pool (like the command does) + $single1 = Product::factory()->withStocks(1)->withPrices(unit_amount: 5000)->create([ + 'slug' => 'parking-spot-a3', + 'name' => 'Spot A3', + 'sku' => 'PARK-NORTH-01', + 'type' => ProductType::BOOKING, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => false, + 'manage_stock' => true, + 'parent_id' => $this->pool->id, + ]); + + $single2 = Product::factory()->withStocks(1)->withPrices(unit_amount: 5000)->create([ + 'slug' => 'parking-spot-a7', + 'name' => 'Spot A7', + 'sku' => 'PARK-NORTH-02', + 'type' => ProductType::BOOKING, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => false, + 'manage_stock' => true, + 'parent_id' => $this->pool->id, + ]); + + // Attach single items to pool + $this->pool->attachSingleItems([ + $single1->id, + $single2->id + ]); + + // Step 3: Add the pool 2 times to the cart (without dates initially) + $this->cart->addToCart($this->pool, 2); + + // Verify 2 items were added + $this->assertEquals(2, $this->cart->items()->count()); + + // Step 4: Set dates (1 day: 2026-01-01T12:00 until 2026-01-02T12:00) + $from = Carbon::parse('2026-01-01 12:00:00'); + $until = Carbon::parse('2026-01-02 12:00:00'); + + $this->cart->setDates($from, $until, validateAvailability: false); + + // Reload cart and items + $cart = $this->cart->fresh(); + $cart->load('items'); + + // EXPECTED BEHAVIOR: + // - Pool has a default price of 2500 (€25/day) + // - Each single item also has a price of 5000 (€50/day) + // - With LOWEST pricing strategy (default), pool should still use individual product prices, if they have one + // - With 1 day duration, each cart item should be 5000 cents + // - Total should be 10000 cents (5000 × 2 items) + + // BUG: Currently shows 5000 per item instead of 2500 + foreach ($cart->items as $item) { + $this->assertEquals( + 5000, + $item->price, + ); + } + + $this->assertEquals( + 5000 * 2, + $cart->getTotal(), + ); + } + + /** + * Test the scenario where pool has LOWEST pricing strategy. + * Pool's price should be used when it's lower than single item prices. + */ + #[Test] + public function it_uses_pool_default_price_when_lower_than_single_prices() + { + // Create pool with default price: 2500 (€25/day) + $this->pool = Product::factory()->withPrices(unit_amount: 2500)->create([ + 'slug' => 'parking-pool', + 'name' => 'Parking Pool', + 'sku' => 'PARK-POOL', + 'type' => ProductType::POOL, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + ]); + + // Create singles with HIGHER prices: 5000 (€50/day) + $singles = []; + for ($i = 1; $i <= 2; $i++) { + $singles[] = Product::factory()->withStocks(1)->withPrices(unit_amount: 5000)->create([ + 'slug' => "spot-{$i}", + 'name' => "Spot {$i}", + 'sku' => "SPOT-{$i}", + 'type' => ProductType::BOOKING, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'parent_id' => $this->pool->id, + ]); + } + + $this->pool->attachSingleItems(array_column($singles, 'id')); + $this->pool->setPricingStrategy(\Blax\Shop\Enums\PricingStrategy::LOWEST); + + // Add pool items with dates directly + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDay()->startOfDay(); // 1 day + + $this->cart->addToCart($this->pool, 2, [], $from, $until); + + // Each item should be 2500 for 1 day + $cart = $this->cart->fresh(); + $this->assertEquals( + 5000 * 2, + $cart->getTotal(), + ); + + foreach ($cart->items as $item) { + $this->assertEquals(5000, $item->price); + } + } + + /** + * Test that adding without dates then setting dates later works correctly. + */ + #[Test] + public function it_correctly_updates_prices_when_dates_are_set_after_adding_to_cart() + { + // Create pool with price 2500 + $this->pool = Product::factory()->withPrices(unit_amount: 2500)->create([ + 'slug' => 'parking-pool', + 'name' => 'Parking Pool', + 'sku' => 'PARK-POOL', + 'type' => ProductType::POOL, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + ]); + + // Create single with price 5000 + $single1 = Product::factory() + ->withPrices(unit_amount: 5000) + ->withStocks(1) + ->create([ + 'slug' => 'spot-1', + 'name' => 'Spot 1', + 'sku' => 'SPOT-1', + 'type' => ProductType::BOOKING, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'parent_id' => $this->pool->id, + ]); + + $single2 = Product::factory() + ->withStocks(1) + ->create([ + 'slug' => 'spot-2', + 'name' => 'Spot 2', + 'sku' => 'SPOT-2', + 'type' => ProductType::BOOKING, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => true, + 'parent_id' => $this->pool->id, + ]); + + $this->pool->attachSingleItems([$single1->id, $single2->id]); + $this->pool->setPricingStrategy(\Blax\Shop\Enums\PricingStrategy::LOWEST); + + // Refresh pool to clear relationship cache after attaching singles + $this->pool = $this->pool->fresh(); + + // Add without dates + $this->cart->addToCart($this->pool, 2); + + // Use latest('id') instead of latest() because both items have same created_at timestamp + $item1 = $this->cart->items()->first(); + $this->assertEquals(2500, $item1->price, 'First item should use pool fallback price (2500) for Single2 which has no price'); + + $item2 = $this->cart->items()->latest('id')->first(); + $this->assertEquals(5000, $item2->price, 'Second item should use Single1 own price (5000)'); + + // Now set dates for 2 days + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days + + $this->cart->setDates($from, $until, validateAvailability: false); + + // After setting dates for 2 days: + // - First item (Single2 with pool fallback 2500/day): 2500 × 2 = 5000 + // - Second item (Single1 with own price 5000/day): 5000 × 2 = 10000 + $item1 = $item1->fresh(); + $item2 = $item2->fresh(); + + $this->assertEquals( + 2500 * 2, // 5000 + $item1->price, + 'First item should be pool fallback price (2500) × 2 days = 5000' + ); + + $this->assertEquals( + 5000 * 2, // 10000 + $item2->price, + 'Second item should be own price (5000) × 2 days = 10000' + ); + + // Asser correct cart total + $this->assertEquals( + (2500 * 2) + (5000 * 2), // 5000 + 10000 = 15000 + $this->cart->getTotal(), + 'Cart total should be sum of both items after date update' + ); + + // Update dates to 1 day + $until = $until->addDay(); + $this->cart->setDates($from, $until); + + // After updating to 3 days: + // - First item: 2500 × 3 = 7500 + // - Second item: 5000 × 3 = 15000 + + $this->assertEquals( + 2500 * 3, // 7500 + $item1->fresh()->price, + 'First item should be pool fallback price (2500) × 3 days = 7500' + ); + } +} diff --git a/tests/Feature/Stripe/StripeChargeFlowTest.php b/tests/Feature/Stripe/StripeChargeFlowTest.php index e3cca45..a92561d 100644 --- a/tests/Feature/Stripe/StripeChargeFlowTest.php +++ b/tests/Feature/Stripe/StripeChargeFlowTest.php @@ -1,6 +1,6 @@