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" ); } } } }