diff --git a/src/Models/Cart.php b/src/Models/Cart.php index c903262..4e9b1e8 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -636,19 +636,28 @@ class Cart extends Model ); } - // For pool products, check if price_id matches to allow proper merging - // Pool items with the same price_id (from the same single item) can merge - // but items from different single items (different price_id) should NOT merge - // Also check that the actual price matches (important for AVERAGE strategy where price can change) + // For pool products, check if we should merge with existing items + // Pool items can ONLY merge if they are from the SAME single item + // This is critical because different single items have their own stock limits + // even if they happen to share the same price (e.g., via pool fallback price) $priceMatch = true; if ($cartable instanceof Product && $cartable->isPool()) { // Calculate expected price for this item $poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until); $expectedPrice = $poolItemData['price'] ?? null; + $expectedSingleItemId = $poolItemData['item']?->id ?? null; - // Only merge if price_id matches AND the price amount matches + // Get the allocated single item ID from the existing cart item's meta + $existingMeta = $item->getMeta(); + $existingAllocatedItemId = $existingMeta->allocated_single_item_id ?? null; + + // Only merge if: + // 1. price_id matches (same price source) + // 2. actual price amount matches + // 3. allocated single item matches (CRITICAL: same single item being used) $priceMatch = $poolPriceId && $item->price_id === $poolPriceId && - $expectedPrice !== null && $item->unit_amount === (int) round($expectedPrice); + $expectedPrice !== null && $item->unit_amount === (int) round($expectedPrice) && + $expectedSingleItemId !== null && $existingAllocatedItemId === $expectedSingleItemId; } return $paramsMatch && $datesMatch && $priceMatch; diff --git a/tests/Feature/CartAddToCartPoolPricingTest.php b/tests/Feature/CartAddToCartPoolPricingTest.php index 2b097a2..191223d 100644 --- a/tests/Feature/CartAddToCartPoolPricingTest.php +++ b/tests/Feature/CartAddToCartPoolPricingTest.php @@ -199,11 +199,18 @@ class CartAddToCartPoolPricingTest extends TestCase $from = Carbon::now()->addDays(1)->startOfDay(); $until = Carbon::now()->addDays(6)->startOfDay(); // 5 days + // Adding 2 pool items creates separate cart items (one per single item) + // because each single item has its own stock limit $cartItem = $this->cart->addToCart($this->poolProduct, 2, [], $from, $until); - $this->assertEquals(2, $cartItem->quantity); + // Returns the last cart item created (quantity 1) + $this->assertEquals(1, $cartItem->quantity); $this->assertEquals(12500, $cartItem->price); // 25.00 × 5 days per unit - $this->assertEquals(25000, $cartItem->subtotal); // 125.00 × 2 units = 250.00 + $this->assertEquals(12500, $cartItem->subtotal); // 125.00 × 1 quantity + + // But total cart should have 2 items with combined subtotal + $this->assertEquals(2, $this->cart->fresh()->items->count()); + $this->assertEquals(25000, $this->cart->fresh()->getTotal()); // 125.00 × 2 units = 250.00 } /** @test */ @@ -324,7 +331,7 @@ class CartAddToCartPoolPricingTest extends TestCase } /** @test */ - public function it_increases_quantity_when_adding_same_pool_product_with_same_dates() + public function it_creates_separate_items_when_adding_same_pool_product_with_same_dates() { ProductPrice::factory()->create([ 'purchasable_id' => $this->poolProduct->id, @@ -340,9 +347,17 @@ class CartAddToCartPoolPricingTest extends TestCase $cartItem1 = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); $cartItem2 = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); - $this->assertEquals($cartItem1->id, $cartItem2->id); - $this->assertEquals(2, $cartItem2->quantity); - $this->assertEquals(12000, $cartItem2->subtotal); // 3000 × 2 days × 2 units + // Items from different single items don't merge, even with same dates + $this->assertNotEquals($cartItem1->id, $cartItem2->id); + $this->assertEquals(1, $cartItem1->quantity); + $this->assertEquals(1, $cartItem2->quantity); + + // Both items have the same price since they use pool fallback + $this->assertEquals(6000, $cartItem1->subtotal); // 3000 × 2 days × 1 unit + $this->assertEquals(6000, $cartItem2->subtotal); // 3000 × 2 days × 1 unit + + // Total cart subtotal + $this->assertEquals(12000, $this->cart->fresh()->getTotal()); // 6000 × 2 = 12000 } /** @test */ @@ -824,10 +839,13 @@ class CartAddToCartPoolPricingTest extends TestCase $availableQuantity = $this->poolProduct->getAvailableQuantity(); $this->assertEquals(2, $availableQuantity); - // Adding 2 pool items should succeed (without dates) + // Adding 2 pool items creates 2 cart items (one per single item) $cartItem = $this->cart->addToCart($this->poolProduct, 2); $this->assertNotNull($cartItem); - $this->assertEquals(2, $cartItem->quantity); + // Returns the last cart item (quantity 1) + $this->assertEquals(1, $cartItem->quantity); + // But total items should be 2 + $this->assertEquals(2, $this->cart->fresh()->items->sum('quantity')); // Try to add 1 more (total would be 3, but only 2 available) $this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class); @@ -888,10 +906,14 @@ class CartAddToCartPoolPricingTest extends TestCase 'customer_type' => get_class($this->user), ]); - // Should be able to add 10 pool items + // Adding 10 pool items creates multiple cart items (grouped by single item) + // Since each single item stock is counted as 5+3+2=10 $cartItem = $cart->addToCart($pool, 10); $this->assertNotNull($cartItem); - $this->assertEquals(10, $cartItem->quantity); + // Returns the last cart item (from VIP Spot with 2 stock) + $this->assertEquals(2, $cartItem->quantity); + // But total items in cart should sum to 10 + $this->assertEquals(10, $cart->fresh()->items->sum('quantity')); // But not 11 $this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class); @@ -944,10 +966,13 @@ class CartAddToCartPoolPricingTest extends TestCase 'customer_type' => get_class($this->user), ]); - // Should be able to book 5 pool items for the period + // Adding 5 pool items creates multiple cart items (grouped by single item) $cartItem = $cart->addToCart($pool, 5, [], $from, $until); $this->assertNotNull($cartItem); - $this->assertEquals(5, $cartItem->quantity); + // Returns the last cart item (from Room B with 2 stock) + $this->assertEquals(2, $cartItem->quantity); + // But total items in cart should sum to 5 + $this->assertEquals(5, $cart->fresh()->items->sum('quantity')); } /** @test */ diff --git a/tests/Feature/PoolProductionBugTest.php b/tests/Feature/PoolProductionBugTest.php new file mode 100644 index 0000000..27969ac --- /dev/null +++ b/tests/Feature/PoolProductionBugTest.php @@ -0,0 +1,377 @@ +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 + */ + 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(); + + // Adding 7 items should throw exception since we only have 6 single items + $this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class); + $this->cart->addToCart($this->pool, 7); + } + + /** @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()); + } +}