diff --git a/database/migrations/create_blax_shop_tables.php.stub b/database/migrations/create_blax_shop_tables.php.stub index f77c7ea..079686c 100644 --- a/database/migrations/create_blax_shop_tables.php.stub +++ b/database/migrations/create_blax_shop_tables.php.stub @@ -242,8 +242,8 @@ return new class extends Migration $table->nullableUuidMorphs('purchasable'); $table->nullableUuidMorphs('purchaser'); $table->integer('quantity')->default(1); - $table->decimal('amount', 10, 8)->nullable(); - $table->decimal('amount_paid', 10, 8)->default(0); + $table->unsignedBigInteger('amount')->nullable(); // Stored in cents + $table->unsignedBigInteger('amount_paid')->default(0); // Stored in cents $table->string('charge_id')->nullable(); $table->timestamp('from')->nullable(); $table->timestamp('until')->nullable(); @@ -308,8 +308,8 @@ return new class extends Migration $table->uuid('cart_id'); $table->string('code')->nullable(); $table->string('type')->default('percentage'); // percentage, fixed, shipping - $table->decimal('amount', 10, 2); - $table->decimal('discount_amount', 10, 2); + $table->unsignedBigInteger('amount'); // Stored in cents + $table->unsignedBigInteger('discount_amount'); // Stored in cents $table->json('meta')->nullable(); $table->timestamps(); diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 4e9b1e8..7f6a507 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -840,10 +840,25 @@ class Cart extends Model /** * Validate cart for checkout without converting it * + * Checks: + * 1. Cart is not already converted + * 2. Cart is not empty + * 3. All items have required information + * 4. Stock is available for all items (for booking/pool products with dates) + * * @throws \Exception */ public function validateForCheckout(bool $throws = true): bool { + // Check if cart is already converted + if ($this->isConverted()) { + if ($throws) { + throw new \Exception("Cart has already been converted/checked out"); + } else { + return false; + } + } + $items = $this->items() ->with('purchasable') ->get(); @@ -872,6 +887,90 @@ class Cart extends Model } } + // Validate stock availability for all items + foreach ($items as $item) { + $product = $item->purchasable; + + if (!($product instanceof Product)) { + continue; + } + + $from = $item->from; + $until = $item->until; + + // For pool products, check pool availability + if ($product->isPool()) { + if ($from && $until) { + // Get available quantity considering existing cart items and pending purchases + $available = $product->getPoolMaxQuantity($from, $until); + + // Calculate how much of this cart's items are already counted + // We need to check if there's still enough stock for what's in this cart + $cartItemsForPool = $items->filter( + fn($i) => + $i->purchasable_id === $product->id && + $i->purchasable_type === get_class($product) + ); + $totalInCart = $cartItemsForPool->sum('quantity'); + + if ($available !== PHP_INT_MAX && $totalInCart > $available) { + if ($throws) { + throw new \Blax\Shop\Exceptions\NotEnoughStockException( + "Pool product '{$product->name}' has only {$available} items available for the period " . + "{$from->format('Y-m-d')} to {$until->format('Y-m-d')}. Cart has: {$totalInCart}" + ); + } else { + return false; + } + } + } else { + // Without dates, check general pool availability + $available = $product->getPoolMaxQuantity(); + $totalInCart = $items->filter( + fn($i) => + $i->purchasable_id === $product->id && + $i->purchasable_type === get_class($product) + )->sum('quantity'); + + if ($available !== PHP_INT_MAX && $totalInCart > $available) { + if ($throws) { + throw new \Blax\Shop\Exceptions\NotEnoughStockException( + "Pool product '{$product->name}' has only {$available} items available. Cart has: {$totalInCart}" + ); + } else { + return false; + } + } + } + } elseif ($product->isBooking() && $product->manage_stock) { + // For booking products with managed stock + if ($from && $until) { + if (!$product->isAvailableForBooking($from, $until, $item->quantity)) { + if ($throws) { + throw new \Blax\Shop\Exceptions\NotEnoughStockException( + "Booking product '{$product->name}' is not available for the period " . + "{$from->format('Y-m-d')} to {$until->format('Y-m-d')}. Requested: {$item->quantity}" + ); + } else { + return false; + } + } + } + } elseif ($product->manage_stock) { + // For regular products with managed stock + $available = $product->getAvailableStock(); + if ($item->quantity > $available) { + if ($throws) { + throw new \Blax\Shop\Exceptions\NotEnoughStockException( + "Product '{$product->name}' has only {$available} items in stock. Requested: {$item->quantity}" + ); + } else { + return false; + } + } + } + } + return true; } diff --git a/tests/Feature/CartCheckoutSessionTest.php b/tests/Feature/CartCheckoutSessionTest.php index 9ab4b07..811f3b5 100644 --- a/tests/Feature/CartCheckoutSessionTest.php +++ b/tests/Feature/CartCheckoutSessionTest.php @@ -33,7 +33,7 @@ class CartCheckoutSessionTest extends TestCase { config(['shop.stripe.enabled' => false]); - $product = Product::factory()->create(); + $product = Product::factory()->create(['manage_stock' => false]); ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, @@ -61,6 +61,7 @@ class CartCheckoutSessionTest extends TestCase $product = Product::factory()->create([ 'name' => 'Test Product', 'short_description' => 'Short desc', + 'manage_stock' => false, ]); ProductPrice::factory()->create([ @@ -95,6 +96,7 @@ class CartCheckoutSessionTest extends TestCase $product = Product::factory()->create([ 'name' => 'Very Long Product Name That Would Be Too Long', 'short_description' => 'Short Name', + 'manage_stock' => false, ]); ProductPrice::factory()->create([ @@ -178,7 +180,7 @@ class CartCheckoutSessionTest extends TestCase config(['shop.stripe.enabled' => true]); config(['services.stripe.secret' => 'sk_test_fake']); - $product = Product::factory()->create(['name' => 'Test Product']); + $product = Product::factory()->create(['name' => 'Test Product', 'manage_stock' => false]); ProductPrice::factory()->create([ 'purchasable_id' => $product->id, @@ -262,8 +264,8 @@ class CartCheckoutSessionTest extends TestCase config(['shop.stripe.enabled' => true]); config(['services.stripe.secret' => 'sk_test_fake']); - $product1 = Product::factory()->create(['name' => 'Product 1']); - $product2 = Product::factory()->create(['name' => 'Product 2']); + $product1 = Product::factory()->create(['name' => 'Product 1', 'manage_stock' => false]); + $product2 = Product::factory()->create(['name' => 'Product 2', 'manage_stock' => false]); ProductPrice::factory()->create([ 'purchasable_id' => $product1->id, @@ -310,7 +312,7 @@ class CartCheckoutSessionTest extends TestCase config(['shop.currency' => 'eur']); config(['services.stripe.secret' => 'sk_test_fake']); - $product = Product::factory()->create(['name' => 'Product']); + $product = Product::factory()->create(['name' => 'Product', 'manage_stock' => false]); ProductPrice::factory()->create([ 'purchasable_id' => $product->id, @@ -345,7 +347,7 @@ class CartCheckoutSessionTest extends TestCase config(['shop.stripe.enabled' => true]); config(['services.stripe.secret' => 'sk_test_fake']); - $product = Product::factory()->create(['name' => 'Product']); + $product = Product::factory()->create(['name' => 'Product', 'manage_stock' => false]); ProductPrice::factory()->create([ 'purchasable_id' => $product->id, diff --git a/tests/Feature/CheckoutStockValidationTest.php b/tests/Feature/CheckoutStockValidationTest.php new file mode 100644 index 0000000..2342d3a --- /dev/null +++ b/tests/Feature/CheckoutStockValidationTest.php @@ -0,0 +1,274 @@ +user = User::factory()->create(); + auth()->login($this->user); + } + + /** + * Create a pool with managed stock single items + */ + protected function createPoolWithManagedStock(): void + { + // Create pool + $this->pool = Product::factory()->create([ + 'name' => 'Test Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Pool default price + ProductPrice::factory()->create([ + 'purchasable_id' => $this->pool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $this->pool->setPoolPricingStrategy('lowest'); + + // Create 2 single items with 1 stock each + $this->singles = []; + + $single1 = Product::factory()->create([ + 'name' => 'Single 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single1->increaseStock(1); + $this->singles[] = $single1; + + $single2 = Product::factory()->create([ + 'name' => 'Single 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single2->increaseStock(1); + $this->singles[] = $single2; + + $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 validate_for_checkout_checks_stock_availability() + { + $this->createPoolWithManagedStock(); + $this->cart = $this->createCart(); + + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDay()->startOfDay(); + + // Add 2 items (all available stock) + $this->cart->addToCart($this->pool, 2, [], $from, $until); + + // Should pass validation + $this->assertTrue($this->cart->validateForCheckout(false)); + + // Now claim stock for the single items (simulating another completed purchase) + foreach ($this->singles as $single) { + $single->claimStock(1, null, $from, $until, 'Test claim'); + } + + // Now validation should fail - stock is claimed by another purchase + $this->assertFalse($this->cart->fresh()->validateForCheckout(false)); + } + + /** @test */ + public function validate_for_checkout_fails_for_out_of_stock_items() + { + $this->createPoolWithManagedStock(); + $this->cart = $this->createCart(); + + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDay()->startOfDay(); + + // Add 2 items + $this->cart->addToCart($this->pool, 2, [], $from, $until); + + // Manually deplete stock (simulating another purchase) + foreach ($this->singles as $single) { + $single->decreaseStock(1); + } + + // Validation should fail because stock is no longer available + $this->assertFalse($this->cart->validateForCheckout(false)); + } + + /** @test */ + public function validate_for_checkout_fails_for_converted_cart() + { + $this->createPoolWithManagedStock(); + $this->cart = $this->createCart(); + + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDay()->startOfDay(); + + $this->cart->addToCart($this->pool, 1, [], $from, $until); + + // Mark cart as converted + $this->cart->update(['converted_at' => now()]); + + // Validation should fail for converted cart + $this->assertFalse($this->cart->fresh()->validateForCheckout(false)); + } + + /** @test */ + public function checkout_session_link_returns_null_for_converted_cart() + { + $this->createPoolWithManagedStock(); + $this->cart = $this->createCart(); + + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDay()->startOfDay(); + + $this->cart->addToCart($this->pool, 1, [], $from, $until); + + // Mark cart as converted + $this->cart->update(['converted_at' => now()]); + + // validateForCheckout should fail for converted cart + $this->assertFalse($this->cart->fresh()->validateForCheckout(false)); + } + + /** @test */ + public function checkout_session_link_returns_null_for_out_of_stock() + { + $this->createPoolWithManagedStock(); + $this->cart = $this->createCart(); + + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDay()->startOfDay(); + + $this->cart->addToCart($this->pool, 2, [], $from, $until); + + // Deplete stock + foreach ($this->singles as $single) { + $single->decreaseStock(1); + } + + // validateForCheckout should fail (stock not available) + $this->assertFalse($this->cart->fresh()->validateForCheckout(false)); + } + + /** @test */ + public function different_date_ranges_allow_booking_same_items() + { + $this->createPoolWithManagedStock(); + $this->cart = $this->createCart(); + + // Book for tomorrow + $from1 = Carbon::tomorrow()->startOfDay(); + $until1 = Carbon::tomorrow()->addDay()->startOfDay(); + + $this->cart->addToCart($this->pool, 2, [], $from1, $until1); + $this->assertTrue($this->cart->validateForCheckout(false)); + + // Create another cart for different dates + $cart2 = $this->createCart(); + + // Book for day after tomorrow (non-overlapping) + $from2 = Carbon::tomorrow()->addDays(2)->startOfDay(); + $until2 = Carbon::tomorrow()->addDays(3)->startOfDay(); + + // This should succeed because dates don't overlap + $cart2->addToCart($this->pool, 2, [], $from2, $until2); + $this->assertTrue($cart2->validateForCheckout(false)); + } + + /** @test */ + public function overlapping_dates_block_double_booking() + { + $this->createPoolWithManagedStock(); + $this->cart = $this->createCart(); + + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDays(3)->startOfDay(); // 3 days + + // Book all stock for 3 days + $this->cart->addToCart($this->pool, 2, [], $from, $until); + $this->assertTrue($this->cart->validateForCheckout(false)); + + // Claim stock (simulating completed purchase) + foreach ($this->singles as $single) { + $single->claimStock(1, null, $from, $until, 'Test claim'); + } + + // Create another cart + $cart2 = $this->createCart(); + + // Try to book overlapping date range (day 2) + $from2 = Carbon::tomorrow()->addDay()->startOfDay(); + $until2 = Carbon::tomorrow()->addDays(2)->startOfDay(); + + // This should fail because dates overlap and all stock is claimed + $this->expectException(NotEnoughStockException::class); + $cart2->addToCart($this->pool, 1, [], $from2, $until2); + } + + /** @test */ + public function partial_stock_allows_partial_booking() + { + $this->createPoolWithManagedStock(); + $this->cart = $this->createCart(); + + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDay()->startOfDay(); + + // Book 1 of 2 available + $this->cart->addToCart($this->pool, 1, [], $from, $until); + $this->assertTrue($this->cart->validateForCheckout(false)); + + // Claim stock for just 1 single item + $this->singles[0]->claimStock(1, null, $from, $until, 'Test claim'); + + // Create another cart - should be able to book 1 more for same dates + $cart2 = $this->createCart(); + $cart2->addToCart($this->pool, 1, [], $from, $until); + $this->assertTrue($cart2->validateForCheckout(false)); + + // But not 2 - only 1 remaining + $cart3 = $this->createCart(); + $this->expectException(NotEnoughStockException::class); + $cart3->addToCart($this->pool, 2, [], $from, $until); + } +} diff --git a/tests/Feature/PoolProductCheckoutTest.php b/tests/Feature/PoolProductCheckoutTest.php index b3f87cb..ead90a1 100644 --- a/tests/Feature/PoolProductCheckoutTest.php +++ b/tests/Feature/PoolProductCheckoutTest.php @@ -316,8 +316,9 @@ class PoolProductCheckoutTest extends TestCase $this->parkingSpot1->claimStock(1, null, $from, $until); $this->parkingSpot2->claimStock(1, null, $from, $until); - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Failed to checkout pool product'); + // validateForCheckout will now catch this before checkout even starts + $this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class); + $this->expectExceptionMessage('has only 1 items available'); $cart->checkout(); }