From 67917e6a31e89fc7f8b468dc9ae89749c7f33b13 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Mon, 15 Dec 2025 11:32:31 +0100 Subject: [PATCH] A booking/pool procedures & tests, IA tests, exceptions --- FACADES_IMPLEMENTATION.md | 167 ---- .../create_blax_shop_tables.php.stub | 2 + src/Enums/ProductType.php | 2 + src/Exceptions/HasNoDefaultPriceException.php | 85 +++ src/Exceptions/HasNoPriceException.php | 60 ++ .../InvalidBookingConfigurationException.php | 158 ++++ .../InvalidPoolConfigurationException.php | 162 ++++ src/Models/Cart.php | 59 +- src/Models/CartItem.php | 4 + src/Models/Product.php | 714 ++++++++++++++++++ src/Services/CartService.php | 205 +++++ src/Traits/HasCart.php | 18 +- .../Feature/BookingTimespanValidationTest.php | 450 +++++++++++ tests/Feature/CartServiceBookingTest.php | 421 +++++++++++ tests/Feature/PoolProductCheckoutTest.php | 430 +++++++++++ tests/Feature/PoolProductPricingTest.php | 427 +++++++++++ tests/Feature/PoolProductTest.php | 611 +++++++++++++++ .../Feature/ProductPricingValidationTest.php | 317 ++++++++ 18 files changed, 4107 insertions(+), 185 deletions(-) delete mode 100644 FACADES_IMPLEMENTATION.md create mode 100644 src/Exceptions/HasNoDefaultPriceException.php create mode 100644 src/Exceptions/HasNoPriceException.php create mode 100644 src/Exceptions/InvalidBookingConfigurationException.php create mode 100644 src/Exceptions/InvalidPoolConfigurationException.php create mode 100644 tests/Feature/BookingTimespanValidationTest.php create mode 100644 tests/Feature/CartServiceBookingTest.php create mode 100644 tests/Feature/PoolProductCheckoutTest.php create mode 100644 tests/Feature/PoolProductPricingTest.php create mode 100644 tests/Feature/PoolProductTest.php create mode 100644 tests/Feature/ProductPricingValidationTest.php diff --git a/FACADES_IMPLEMENTATION.md b/FACADES_IMPLEMENTATION.md deleted file mode 100644 index 763f7ac..0000000 --- a/FACADES_IMPLEMENTATION.md +++ /dev/null @@ -1,167 +0,0 @@ -# Shop and Cart Facades Implementation Summary - -## Overview - -Successfully implemented two core Facades for the Laravel Shop package to simplify the API for Laravel developers. - -## Files Created - -### 1. **Facades** - -#### `src/Facades/Shop.php` -- Static accessor for shop-related functionality -- Provides convenient methods for product browsing and inventory management -- Type-hinted methods for IDE autocomplete support - -#### `src/Facades/Cart.php` -- Static accessor for shopping cart operations -- Simplifies cart management without needing authentication context -- Type-hinted methods for IDE autocomplete support - -### 2. **Services** - -#### `src/Services/ShopService.php` -Core implementation for shop operations: -- `products()` - Get all products query builder -- `product($id)` - Get single product -- `categories()` - Get all categories -- `inStock()` - Get in-stock products -- `featured()` - Get featured products -- `published()` - Get published and visible products -- `search($query)` - Search products -- `checkStock($product, $quantity)` - Verify stock availability -- `getAvailableStock($product)` - Get available quantity -- `isOnSale($product)` - Check if product is on sale -- `config($key, $default)` - Get shop configuration -- `currency()` - Get default currency - -#### `src/Services/CartService.php` -Core implementation for cart operations: -- `current()` - Get current authenticated user's cart -- `forUser($user)` - Get cart for specific user -- `find($cartId)` - Find cart by ID -- `add($product, $quantity, $parameters)` - Add item to cart -- `remove($product, $quantity, $parameters)` - Remove item from cart -- `update($cartItem, $quantity)` - Update item quantity -- `clear()` - Clear cart -- `checkout()` - Checkout cart -- `total()` - Get cart total -- `itemCount()` - Get item count -- `items()` - Get cart items -- `isEmpty()` - Check if empty -- `isExpired()` - Check if expired -- `isConverted()` - Check if converted -- `unpaidAmount()` - Get unpaid amount -- `paidAmount()` - Get paid amount - -### 3. **Service Provider Updates** - -Updated `src/ShopServiceProvider.php` to: -- Bind `shop.service` to `ShopService` in the container -- Bind `shop.cart` to `CartService` in the container -- Register both facades for easy access throughout the application - -## Test Coverage - -### `tests/Feature/ShopFacadeTest.php` (23 tests) -Tests for Shop facade functionality: -- Product retrieval and filtering -- Category access -- Stock checking -- Search functionality -- Configuration access -- Query builder chaining -- Pagination support - -### `tests/Feature/CartFacadeTest.php` (26 tests) -Tests for Cart facade functionality: -- Cart retrieval and creation -- Adding items with parameters -- Removing items -- Updating quantities -- Cart clearing and checkout -- Total and count calculations -- Cart status checks -- Paid/unpaid amount tracking -- Multi-product operations - -## Test Results - -✅ **All 49 new tests pass** -✅ **All 391 total tests pass** (including existing tests) -✅ **7 tests skipped** (intentional) -✅ **No regressions** to existing functionality - -## Usage Examples - -### Shop Facade - -```php -use Blax\Shop\Facades\Shop; - -// Get featured products -$featured = Shop::featured()->with('prices')->get(); - -// Check stock availability -if (Shop::checkStock($product, 2)) { - // Add to cart -} - -// Search products -$results = Shop::search('laptop')->paginate(10); - -// Get available stock -$available = Shop::getAvailableStock($product); -``` - -### Cart Facade - -```php -use Blax\Shop\Facades\Cart; - -// Add to cart -Cart::add($product, quantity: 2, parameters: ['size' => 'L']); - -// Get cart info -$total = Cart::total(); -$count = Cart::itemCount(); -$items = Cart::items(); - -// Update and manage -Cart::update($cartItem, quantity: 5); -Cart::remove($product, quantity: 1); - -// Checkout -$purchases = Cart::checkout(); -``` - -## Benefits - -1. **Cleaner Code**: No need for `auth()->user()->currentCart()->getTotal()` -2. **Better Testing**: Easy to mock with `Cart::shouldReceive()` -3. **IDE Support**: Static methods provide excellent autocomplete -4. **Consistent Interface**: Unified API across the package -5. **Type Safety**: All methods are properly type-hinted -6. **Documentation**: Methods are self-documenting through type hints - -## Future Improvements - -Consider implementing additional facades: -- `Inventory` - For stock management -- `Purchase` - For purchase operations -- `Stripe` - For payment processing - -These were outlined in the `FACADE_SUGGESTIONS.md` document and can be implemented using the same pattern. - -## Integration - -The facades are automatically registered in the service container through the `ShopServiceProvider`. They're ready to use immediately after the package is installed: - -```php -// No additional configuration needed! -use Blax\Shop\Facades\Shop; -use Blax\Shop\Facades\Cart; - -Shop::featured(); -Cart::add($product); -``` diff --git a/database/migrations/create_blax_shop_tables.php.stub b/database/migrations/create_blax_shop_tables.php.stub index 7b94837..fa24fad 100644 --- a/database/migrations/create_blax_shop_tables.php.stub +++ b/database/migrations/create_blax_shop_tables.php.stub @@ -282,6 +282,8 @@ return new class extends Migration $table->decimal('subtotal', 10, 2); $table->json('parameters')->nullable(); $table->json('meta')->nullable(); + $table->timestamp('from')->nullable(); + $table->timestamp('until')->nullable(); $table->timestamps(); $table->index(['cart_id', 'purchasable_id']); diff --git a/src/Enums/ProductType.php b/src/Enums/ProductType.php index db0767e..e8cd053 100644 --- a/src/Enums/ProductType.php +++ b/src/Enums/ProductType.php @@ -10,6 +10,7 @@ enum ProductType: string case EXTERNAL = 'external'; case BOOKING = 'booking'; case VARIATION = 'variation'; + case POOL = 'pool'; public function label(): string { @@ -20,6 +21,7 @@ enum ProductType: string self::EXTERNAL => 'External', self::BOOKING => 'Booking', self::VARIATION => 'Variation', + self::POOL => 'Pool', }; } } diff --git a/src/Exceptions/HasNoDefaultPriceException.php b/src/Exceptions/HasNoDefaultPriceException.php new file mode 100644 index 0000000..12570f9 --- /dev/null +++ b/src/Exceptions/HasNoDefaultPriceException.php @@ -0,0 +1,85 @@ +id)\n" . + " ->where('purchasable_type', Product::class)\n" . + " ->first();\n" . + "\$price->update(['is_default' => true]);\n\n" . + "// Option 2: When creating new prices, always set is_default\n" . + "ProductPrice::create([\n" . + " 'purchasable_id' => \$product->id,\n" . + " 'purchasable_type' => Product::class,\n" . + " 'unit_amount' => 10000,\n" . + " 'currency' => 'USD',\n" . + " 'is_default' => true, // ✓ Always set this for the primary price\n" . + "]);\n\n" . + "Why this matters:\n" . + "- The default price is used when adding products to cart\n" . + "- Without a default, the system can't determine which price to use\n" . + "- Multiple default prices will cause conflicts\n\n" . + "Current state: {$priceCount} prices exist, 0 are marked as default" + ); + } + + public static function onlyNonDefaultPriceExists(string $productName): self + { + return new self( + "Product '{$productName}' has a price configured, but it's not marked as default.\n\n" . + "When a product has only one price, it should be marked as default.\n\n" . + "To fix this:\n\n" . + "use Blax\Shop\Models\ProductPrice;\n\n" . + "\$price = ProductPrice::where('purchasable_id', \$product->id)\n" . + " ->where('purchasable_type', Product::class)\n" . + " ->first();\n" . + "\n" . + "\$price->update(['is_default' => true]);\n\n" . + "Or when creating the price:\n\n" . + "ProductPrice::create([\n" . + " 'purchasable_id' => \$product->id,\n" . + " 'purchasable_type' => Product::class,\n" . + " 'unit_amount' => 10000,\n" . + " 'currency' => 'USD',\n" . + " 'is_default' => true, // ✓ Required\n" . + "]);\n\n" . + "Note: If you have only one price, it must be the default price." + ); + } + + public static function multipleDefaultPrices(string $productName, int $defaultCount): self + { + return new self( + "Product '{$productName}' has {$defaultCount} prices marked as default. Only one price can be default.\n\n" . + "To fix this, keep only one price as default:\n\n" . + "use Blax\Shop\Models\ProductPrice;\n\n" . + "// Get all default prices\n" . + "\$defaultPrices = ProductPrice::where('purchasable_id', \$product->id)\n" . + " ->where('purchasable_type', Product::class)\n" . + " ->where('is_default', true)\n" . + " ->get();\n\n" . + "// Keep the first one as default, set others to non-default\n" . + "\$defaultPrices->skip(1)->each(function (\$price) {\n" . + " \$price->update(['is_default' => false]);\n" . + "});\n\n" . + "Why this matters:\n" . + "- Only one price should be used as the default for cart operations\n" . + "- Multiple defaults create ambiguity\n" . + "- The system can't determine which price to use\n\n" . + "Best practice:\n" . + "- Use is_default => true for the standard price\n" . + "- Use is_default => false for alternative prices (bulk discounts, regions, etc.)\n" . + "- Implement custom logic to select non-default prices when needed\n\n" . + "Current state: {$defaultCount} prices are marked as default" + ); + } +} diff --git a/src/Exceptions/HasNoPriceException.php b/src/Exceptions/HasNoPriceException.php new file mode 100644 index 0000000..b1c14c9 --- /dev/null +++ b/src/Exceptions/HasNoPriceException.php @@ -0,0 +1,60 @@ + '{$productId}',\n" . + " 'purchasable_type' => Product::class,\n" . + " 'unit_amount' => 10000, // Price in cents (100.00)\n" . + " 'currency' => 'USD',\n" . + " 'is_default' => true, // Mark as default price\n" . + "]);\n\n" . + "For booking/pool products:\n" . + "- The unit_amount is typically the price per day\n" . + "- Total price = unit_amount × days × quantity\n\n" . + "For simple products:\n" . + "- The unit_amount is the product price\n" . + "- Total price = unit_amount × quantity" + ); + } + + public static function poolProductNoPriceAndNoSingleItemPrices(string $productName): self + { + return new self( + "Pool product '{$productName}' has no pricing configured.\n\n" . + "Pool products need pricing through one of two methods:\n\n" . + "Option 1: Direct pool pricing (Recommended)\n" . + "ProductPrice::create([\n" . + " 'purchasable_id' => \$poolProduct->id,\n" . + " 'purchasable_type' => Product::class,\n" . + " 'unit_amount' => 5000, // 50.00 per day\n" . + " 'currency' => 'USD',\n" . + " 'is_default' => true,\n" . + "]);\n\n" . + "Option 2: Price inheritance from single items\n" . + "// Set prices on individual items in the pool\n" . + "foreach (\$poolProduct->singleProducts as \$item) {\n" . + " ProductPrice::create([\n" . + " 'purchasable_id' => \$item->id,\n" . + " 'purchasable_type' => Product::class,\n" . + " 'unit_amount' => 5000,\n" . + " 'currency' => 'USD',\n" . + " 'is_default' => true,\n" . + " ]);\n" . + "}\n\n" . + "// Configure pricing strategy (optional)\n" . + "\$poolProduct->setPoolPricingStrategy('average'); // or 'lowest' or 'highest'\n\n" . + "Current state:\n" . + "- Pool product has no direct price\n" . + "- No single items have prices to inherit from" + ); + } +} diff --git a/src/Exceptions/InvalidBookingConfigurationException.php b/src/Exceptions/InvalidBookingConfigurationException.php new file mode 100644 index 0000000..7220465 --- /dev/null +++ b/src/Exceptions/InvalidBookingConfigurationException.php @@ -0,0 +1,158 @@ + ProductType::BOOKING,\n" . + " 'name' => 'Conference Room A',\n" . + " 'manage_stock' => true, // Required for bookings\n" . + "]);\n\n" . + "2. Set initial stock (typically 1 for a single bookable unit):\n" . + "\$product->increaseStock(1);\n\n" . + "3. Set pricing (per day/hour):\n" . + "ProductPrice::create([\n" . + " 'purchasable_id' => \$product->id,\n" . + " 'purchasable_type' => Product::class,\n" . + " 'unit_amount' => 10000, // Price per day in cents\n" . + " 'currency' => 'USD',\n" . + " 'is_default' => true,\n" . + "]);" + ); + } + + public static function stockManagementNotEnabled(string $productName): self + { + return new self( + "Booking product '{$productName}' does not have stock management enabled.\n\n" . + "Stock management is required for booking products to track availability.\n\n" . + "To enable:\n" . + "\$product->update(['manage_stock' => true]);\n" . + "\$product->increaseStock(1);" + ); + } + + public static function noStockAvailable(string $productName): self + { + return new self( + "Booking product '{$productName}' has no stock available.\n\n" . + "For booking products, stock represents the number of bookable units.\n" . + "Typically, set stock to 1 for single-unit items (rooms, equipment, etc.)\n\n" . + "To add stock:\n" . + "\$product->increaseStock(1);" + ); + } + + public static function invalidTimespan(\DateTimeInterface $from, \DateTimeInterface $until): self + { + return new self( + "Invalid booking timespan: from '{$from->format('Y-m-d H:i:s')}' to '{$until->format('Y-m-d H:i:s')}'\n\n" . + "Booking validation rules:\n" . + "1. 'from' must be before 'until'\n" . + "2. 'from' cannot be in the past\n" . + "3. Both dates must be provided\n\n" . + "Example:\n" . + "Cart::addBooking(\n" . + " \$product,\n" . + " 1,\n" . + " Carbon::now()->addDay(), // from (future date)\n" . + " Carbon::now()->addDays(3), // until (after 'from')\n" . + ");" + ); + } + + public static function timespanRequired(string $productName): self + { + return new self( + "Booking product '{$productName}' requires a timespan (from/until dates).\n\n" . + "When adding a booking product to cart, you must specify when it will be used:\n\n" . + "Using CartService:\n" . + "use Blax\Shop\Facades\Cart;\n\n" . + "Cart::addBooking(\n" . + " \$bookingProduct,\n" . + " \$quantity,\n" . + " Carbon::parse('2025-01-15 09:00'), // from\n" . + " Carbon::parse('2025-01-17 17:00'), // until\n" . + ");\n\n" . + "Or when creating cart items manually:\n" . + "\$cart->items()->create([\n" . + " 'purchasable_id' => \$product->id,\n" . + " 'purchasable_type' => Product::class,\n" . + " 'quantity' => 1,\n" . + " 'from' => Carbon::parse('2025-01-15 09:00'),\n" . + " 'until' => Carbon::parse('2025-01-17 17:00'),\n" . + " 'price' => \$product->getCurrentPrice() * 2, // 2 days\n" . + " 'subtotal' => \$product->getCurrentPrice() * 2,\n" . + "]);" + ); + } + + public static function notAvailableForPeriod( + string $productName, + \DateTimeInterface $from, + \DateTimeInterface $until, + int $requested, + int $available + ): self { + return new self( + "Booking product '{$productName}' is not available for the requested period.\n\n" . + "Period: {$from->format('Y-m-d H:i:s')} to {$until->format('Y-m-d H:i:s')}\n" . + "Requested quantity: {$requested}\n" . + "Available quantity: {$available}\n\n" . + "Possible reasons:\n" . + "- Another booking overlaps with this period\n" . + "- Not enough stock for the requested quantity\n" . + "- Stock claims exist for this period\n\n" . + "To check availability:\n" . + "\$available = \$product->isAvailableForBooking(\$from, \$until, \$quantity);\n\n" . + "To see existing claims:\n" . + "\$claims = \$product->stocks()\n" . + " ->where('type', StockType::CLAIMED)\n" . + " ->where('status', StockStatus::PENDING)\n" . + " ->whereBetween('claimed_from', [\$from, \$until])\n" . + " ->get();" + ); + } + + public static function overlappingBooking( + string $productName, + \DateTimeInterface $from, + \DateTimeInterface $until + ): self { + return new self( + "Booking overlaps with existing reservations for '{$productName}'.\n\n" . + "Requested period: {$from->format('Y-m-d H:i:s')} to {$until->format('Y-m-d H:i:s')}\n\n" . + "Overlap detection rules:\n" . + "1. New booking starts during existing booking\n" . + "2. New booking ends during existing booking\n" . + "3. New booking completely contains existing booking\n" . + "4. New booking is completely contained by existing booking\n\n" . + "Note: Back-to-back bookings are allowed (e.g., 23:59:59 → 00:00:00)" + ); + } + + public static function noPricingConfigured(string $productName): self + { + return new self( + "Booking product '{$productName}' has no pricing configured.\n\n" . + "To set pricing:\n\n" . + "use Blax\Shop\Models\ProductPrice;\n\n" . + "ProductPrice::create([\n" . + " 'purchasable_id' => \$product->id,\n" . + " 'purchasable_type' => Product::class,\n" . + " 'unit_amount' => 10000, // Price per day in cents (100.00 USD)\n" . + " 'currency' => 'USD',\n" . + " 'is_default' => true,\n" . + "]);\n\n" . + "The price will be multiplied by the number of days in the booking period.\n" . + "Example: 100.00/day × 3 days = 300.00 total" + ); + } +} diff --git a/src/Exceptions/InvalidPoolConfigurationException.php b/src/Exceptions/InvalidPoolConfigurationException.php new file mode 100644 index 0000000..4585917 --- /dev/null +++ b/src/Exceptions/InvalidPoolConfigurationException.php @@ -0,0 +1,162 @@ +productRelations()->attach(\$item->id, ['type' => ProductRelationType::SINGLE])\n" . + "3. Ensure single items are ProductType::BOOKING for time-based bookings" + ); + } + + public static function noSingleItems(string $productName): self + { + return new self( + "Pool product '{$productName}' has no single items attached. " . + "A pool product must have at least one single item.\n\n" . + "To add single items:\n" . + "\$pool->productRelations()->attach(\$singleItem->id, [\n" . + " 'type' => ProductRelationType::SINGLE\n" . + "]);\n\n" . + "Example for parking spots:\n" . + "\$parkingLot = Product::create(['type' => ProductType::POOL, 'name' => 'Parking Lot']);\n" . + "\$spot1 = Product::create(['type' => ProductType::BOOKING, 'name' => 'Spot 1']);\n" . + "\$spot2 = Product::create(['type' => ProductType::BOOKING, 'name' => 'Spot 2']);\n" . + "\$parkingLot->productRelations()->attach([\$spot1->id, \$spot2->id], ['type' => ProductRelationType::SINGLE]);" + ); + } + + public static function mixedSingleItemTypes(string $productName): self + { + return new self( + "Pool product '{$productName}' contains mixed single item types. " . + "While this is allowed, it may cause unexpected behavior.\n\n" . + "Best practices:\n" . + "- For time-based bookings: All single items should be ProductType::BOOKING\n" . + "- For quantity-based pools: All single items should be ProductType::SIMPLE\n" . + "- Mixed types will ignore timespans for non-booking items\n\n" . + "Current recommendation: Use consistent product types within a pool." + ); + } + + public static function singleItemsWithoutStock(string $productName, array $itemNames): self + { + $items = implode(', ', $itemNames); + return new self( + "Pool product '{$productName}' has single items without stock management enabled: {$items}\n\n" . + "To enable stock management:\n" . + "\$product->update(['manage_stock' => true]);\n" . + "\$product->increaseStock(1); // Set initial stock\n\n" . + "Why this matters:\n" . + "- Pool products claim stock from single items during checkout\n" . + "- Without stock management, availability cannot be tracked\n" . + "- This will cause checkout failures" + ); + } + + public static function singleItemsWithZeroStock(string $productName, array $itemNames): self + { + $items = implode(', ', $itemNames); + return new self( + "Pool product '{$productName}' has single items with zero available stock: {$items}\n\n" . + "To add stock:\n" . + "\$product->increaseStock(1);\n\n" . + "Note: Each booking item typically has stock of 1, representing one bookable unit." + ); + } + + public static function bookingItemsRequireTimespan(string $productName): self + { + return new self( + "Pool product '{$productName}' contains booking items but no timespan was provided.\n\n" . + "When adding a pool product with booking items to cart, you must specify a timespan:\n\n" . + "Using CartService:\n" . + "Cart::addBooking(\n" . + " \$poolProduct,\n" . + " \$quantity,\n" . + " Carbon::parse('2025-01-15 14:00'), // from\n" . + " Carbon::parse('2025-01-15 18:00'), // until\n" . + ");\n\n" . + "Using Cart directly:\n" . + "\$cart->items()->create([\n" . + " 'purchasable_id' => \$poolProduct->id,\n" . + " 'purchasable_type' => Product::class,\n" . + " 'quantity' => 1,\n" . + " 'from' => Carbon::parse('2025-01-15 14:00'),\n" . + " 'until' => Carbon::parse('2025-01-15 18:00'),\n" . + " // ... other fields\n" . + "]);" + ); + } + + public static function invalidPricingStrategy(string $strategy): self + { + return new self( + "Invalid pricing strategy: '{$strategy}'\n\n" . + "Supported pricing strategies:\n" . + "- 'average' (default): Average price of all single items\n" . + "- 'lowest': Minimum price among single items\n" . + "- 'highest': Maximum price among single items\n\n" . + "Example:\n" . + "\$poolProduct->setPoolPricingStrategy('lowest');" + ); + } + + public static function poolWithoutPricing(string $productName): self + { + return new self( + "Pool product '{$productName}' has no pricing information.\n\n" . + "You have two options:\n\n" . + "Option 1: Set direct pool pricing (takes precedence)\n" . + "ProductPrice::create([\n" . + " 'purchasable_id' => \$poolProduct->id,\n" . + " 'purchasable_type' => Product::class,\n" . + " 'unit_amount' => 5000, // Price in cents\n" . + " 'currency' => 'USD',\n" . + " 'is_default' => true,\n" . + "]);\n\n" . + "Option 2: Set prices on single items (pool will inherit)\n" . + "ProductPrice::create([\n" . + " 'purchasable_id' => \$singleItem->id,\n" . + " 'purchasable_type' => Product::class,\n" . + " 'unit_amount' => 5000,\n" . + " 'currency' => 'USD',\n" . + " 'is_default' => true,\n" . + "]);\n\n" . + "The pool will inherit prices using the configured strategy (average/lowest/highest)." + ); + } + + public static function notEnoughAvailableItems( + string $productName, + \DateTimeInterface $from, + \DateTimeInterface $until, + int $requested, + int $available + ): self { + return new self( + "Pool product '{$productName}' does not have enough available items for the requested period.\n\n" . + "Period: {$from->format('Y-m-d H:i:s')} to {$until->format('Y-m-d H:i:s')}\n" . + "Requested quantity: {$requested}\n" . + "Available quantity: {$available}\n\n" . + "Possible reasons:\n" . + "- Single items are already booked for this period\n" . + "- Not enough single items in the pool\n" . + "- Single items don't have sufficient stock\n\n" . + "To check availability:\n" . + "\$available = \$poolProduct->getPoolMaxQuantity(\$from, \$until);\n\n" . + "To see pool composition:\n" . + "\$singleItems = \$poolProduct->singleProducts;\n" . + "foreach (\$singleItems as \$item) {\n" . + " \$available = \$item->isAvailableForBooking(\$from, \$until, 1);\n" . + " echo \"\$item->name: \" . (\$available ? 'Available' : 'Unavailable') . \"\\n\";\n" . + "}" + ); + } +} diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 7c2902d..2039747 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -248,21 +248,56 @@ class Cart extends Model $product = $item->purchasable; $quantity = $item->quantity; - // Extract booking dates from parameters if this is a booking product - $from = null; - $until = null; - if ($product->type === ProductType::BOOKING && $item->parameters) { - $params = is_array($item->parameters) ? $item->parameters : (array) $item->parameters; - $from = $params['from'] ?? null; - $until = $params['until'] ?? null; + // Get booking dates from cart item directly (preferred) or from parameters (legacy) + $from = $item->from; + $until = $item->until; - // Convert to Carbon instances if they're strings - if ($from && is_string($from)) { - $from = \Carbon\Carbon::parse($from); + if (!$from || !$until) { + if (($product->type === ProductType::BOOKING || $product->type === ProductType::POOL) && $item->parameters) { + $params = is_array($item->parameters) ? $item->parameters : (array) $item->parameters; + $from = $params['from'] ?? null; + $until = $params['until'] ?? null; + + // Convert to Carbon instances if they're strings + if ($from && is_string($from)) { + $from = \Carbon\Carbon::parse($from); + } + if ($until && is_string($until)) { + $until = \Carbon\Carbon::parse($until); + } } - if ($until && is_string($until)) { - $until = \Carbon\Carbon::parse($until); + } + + // Handle pool products with booking single items + if ($product instanceof Product && $product->isPool()) { + // Check if pool with booking items requires timespan + if ($product->hasBookingSingleItems() && (!$from || !$until)) { + throw new \Exception("Pool product '{$product->name}' with booking items requires a timespan (from/until dates)."); } + + // If pool has timespan and has booking single items, claim stock from single items + if ($from && $until && $product->hasBookingSingleItems()) { + try { + $claimedItems = $product->claimPoolStock( + $quantity, + $this, + $from, + $until, + "Checkout from cart {$this->id}" + ); + + // Store claimed items info in purchase meta + $item->updateMetaKey('claimed_single_items', array_map(fn($i) => $i->id, $claimedItems)); + $item->save(); + } catch (\Exception $e) { + throw new \Exception("Failed to checkout pool product '{$product->name}': " . $e->getMessage()); + } + } + } + + // Validate booking products have required dates + if ($product instanceof Product && $product->isBooking() && (!$from || !$until)) { + throw new \Exception("Booking product '{$product->name}' requires a timespan (from/until dates)."); } $purchase = $this->customer->purchase( diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index 43d2a44..d39b1b5 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -22,6 +22,8 @@ class CartItem extends Model 'parameters', 'purchase_id', 'meta', + 'from', + 'until', ]; protected $casts = [ @@ -31,6 +33,8 @@ class CartItem extends Model 'subtotal' => 'decimal:2', 'parameters' => 'array', 'meta' => 'array', + 'from' => 'datetime', + 'until' => 'datetime', ]; public function __construct(array $attributes = []) diff --git a/src/Models/Product.php b/src/Models/Product.php index 3c72eca..51b83d1 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -11,6 +11,10 @@ use Blax\Shop\Enums\ProductStatus; use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockType; +use Blax\Shop\Exceptions\HasNoDefaultPriceException; +use Blax\Shop\Exceptions\HasNoPriceException; +use Blax\Shop\Exceptions\InvalidBookingConfigurationException; +use Blax\Shop\Exceptions\InvalidPoolConfigurationException; use Blax\Shop\Traits\HasCategories; use Blax\Shop\Traits\HasPrices; use Blax\Shop\Traits\HasProductRelations; @@ -312,6 +316,148 @@ class Product extends Model implements Purchasable, Cartable return $this->type === ProductType::BOOKING; } + /** + * Check if this is a pool product + */ + public function isPool(): bool + { + return $this->type === ProductType::POOL; + } + + /** + * Get the maximum available quantity for a pool product based on single items + */ + public function getPoolMaxQuantity(\DateTimeInterface $from = null, \DateTimeInterface $until = null): int + { + if (!$this->isPool()) { + return $this->getAvailableStock(); + } + + $singleItems = $this->singleProducts; + + if ($singleItems->isEmpty()) { + return 0; + } + + // If no dates provided, return the count of single items + if (!$from || !$until) { + return $singleItems->count(); + } + + // Check availability for each single item during the timespan + $availableCount = 0; + foreach ($singleItems as $item) { + if ($item->isAvailableForBooking($from, $until, 1)) { + $availableCount++; + } + } + + return $availableCount; + } + + /** + * Claim stock for a pool product + * This will claim stock from the available single items + * + * @param int $quantity Number of pool items to claim + * @param mixed $reference Reference model + * @param \DateTimeInterface|null $from Start date + * @param \DateTimeInterface|null $until End date + * @param string|null $note Optional note + * @return array Array of claimed single item products + * @throws \Exception + */ + public function claimPoolStock( + int $quantity, + $reference = null, + ?\DateTimeInterface $from = null, + ?\DateTimeInterface $until = null, + ?string $note = null + ): array { + if (!$this->isPool()) { + throw new \Exception('This method is only for pool products'); + } + + $singleItems = $this->singleProducts; + + if ($singleItems->isEmpty()) { + throw new \Exception('Pool product has no single items to claim'); + } + + // Get available single items for the period + $availableItems = []; + foreach ($singleItems as $item) { + if ($item->isAvailableForBooking($from, $until, 1)) { + $availableItems[] = $item; + } + + if (count($availableItems) >= $quantity) { + break; + } + } + + if (count($availableItems) < $quantity) { + throw new \Exception("Only " . count($availableItems) . " items available, but {$quantity} requested"); + } + + // Claim stock from each selected single item + $claimedItems = []; + foreach (array_slice($availableItems, 0, $quantity) as $item) { + $item->claimStock(1, $reference, $from, $until, $note); + $claimedItems[] = $item; + } + + return $claimedItems; + } + + /** + * Release pool stock claims + * + * @param mixed $reference Reference model used when claiming + * @return int Number of claims released + */ + public function releasePoolStock($reference): int + { + if (!$this->isPool()) { + throw new \Exception('This method is only for pool products'); + } + + $singleItems = $this->singleProducts; + $released = 0; + + foreach ($singleItems as $item) { + $referenceType = is_object($reference) ? get_class($reference) : null; + $referenceId = is_object($reference) ? $reference->id : null; + + // Find and delete claims for this reference + $claims = $item->stocks() + ->where('type', StockType::CLAIMED->value) + ->where('status', StockStatus::PENDING->value) + ->where('reference_type', $referenceType) + ->where('reference_id', $referenceId) + ->get(); + + foreach ($claims as $claim) { + $claim->release(); + $released++; + } + } + + return $released; + } + + /** + * Check if any single item in pool is a booking product + */ + public function hasBookingSingleItems(): bool + { + if (!$this->isPool()) { + return false; + } + + return $this->singleProducts()->where('products.type', ProductType::BOOKING->value)->exists(); + } + /** * Check stock availability for a booking period */ @@ -359,4 +505,572 @@ class Product extends Model implements Purchasable, Cartable { return $query->where('type', ProductType::BOOKING->value); } + + /** + * Get the current price with pool product inheritance support + */ + public function getCurrentPrice(bool|null $sales_price = null): ?float + { + // If this is a pool product and it has no direct price, inherit from single items + if ($this->isPool() && !$this->hasPrice()) { + return $this->getInheritedPoolPrice(); + } + + // If pool has a direct price, use it + if ($this->isPool() && $this->hasPrice()) { + return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale()); + } + + // For non-pool products, use the trait's default behavior + return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale()); + } + + /** + * Get inherited price from single items based on pricing strategy + */ + protected function getInheritedPoolPrice(): ?float + { + if (!$this->isPool()) { + return null; + } + + $strategy = $this->getPoolPricingStrategy(); + + $singleItems = $this->singleProducts; + + if ($singleItems->isEmpty()) { + return null; + } + + $prices = $singleItems->map(function ($item) { + return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale()); + })->filter()->values(); + + if ($prices->isEmpty()) { + return null; + } + + return match ($strategy) { + 'lowest' => $prices->min(), + 'highest' => $prices->max(), + 'average' => round($prices->avg()), + default => round($prices->avg()), // Default to average + }; + } + + /** + * Get the pool pricing strategy from metadata + */ + public function getPoolPricingStrategy(): string + { + if (!$this->isPool()) { + return 'average'; + } + + $meta = $this->getMeta(); + return $meta->pricing_strategy ?? 'average'; + } + + /** + * Set the pool pricing strategy + */ + public function setPoolPricingStrategy(string $strategy): void + { + if (!$this->isPool()) { + throw new \Exception('This method is only for pool products'); + } + + if (!in_array($strategy, ['average', 'lowest', 'highest'])) { + throw new \InvalidArgumentException("Invalid pricing strategy: {$strategy}"); + } + + $this->updateMetaKey('pricing_strategy', $strategy); + $this->save(); + } + + /** + * Get the lowest price from single items + */ + public function getLowestPoolPrice(): ?float + { + if (!$this->isPool()) { + return null; + } + + $singleItems = $this->singleProducts; + + if ($singleItems->isEmpty()) { + return null; + } + + $prices = $singleItems->map(function ($item) { + return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale()); + })->filter()->values(); + + return $prices->isEmpty() ? null : $prices->min(); + } + + /** + * Get the highest price from single items + */ + public function getHighestPoolPrice(): ?float + { + if (!$this->isPool()) { + return null; + } + + $singleItems = $this->singleProducts; + + if ($singleItems->isEmpty()) { + return null; + } + + $prices = $singleItems->map(function ($item) { + return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale()); + })->filter()->values(); + + return $prices->isEmpty() ? null : $prices->max(); + } + + /** + * Get the price range for pool products + */ + public function getPoolPriceRange(): ?array + { + if (!$this->isPool()) { + return null; + } + + $lowest = $this->getLowestPoolPrice(); + $highest = $this->getHighestPoolPrice(); + + if ($lowest === null || $highest === null) { + return null; + } + + return [ + 'min' => $lowest, + 'max' => $highest, + ]; + } + + /** + * Validate pool product configuration and provide helpful error messages + * + * @throws InvalidPoolConfigurationException + */ + public function validatePoolConfiguration(bool $throwOnWarnings = false): array + { + $errors = []; + $warnings = []; + + if (!$this->isPool()) { + throw InvalidPoolConfigurationException::notAPoolProduct($this->name); + } + + $singleItems = $this->singleProducts; + + // Critical: No single items + if ($singleItems->isEmpty()) { + throw InvalidPoolConfigurationException::noSingleItems($this->name); + } + + // Check for mixed product types + $types = $singleItems->pluck('type')->unique(); + if ($types->count() > 1) { + $warning = "Mixed single item types detected. This may cause unexpected behavior."; + $warnings[] = $warning; + if ($throwOnWarnings) { + throw InvalidPoolConfigurationException::mixedSingleItemTypes($this->name); + } + } + + // Check stock management on single items + $itemsWithoutStock = $singleItems->filter(fn($item) => !$item->manage_stock); + if ($itemsWithoutStock->isNotEmpty()) { + $itemNames = $itemsWithoutStock->pluck('name')->toArray(); + $errors[] = "Single items without stock management: " . implode(', ', $itemNames); + throw InvalidPoolConfigurationException::singleItemsWithoutStock($this->name, $itemNames); + } + + // Check for items with zero stock + $itemsWithZeroStock = $singleItems->filter(fn($item) => $item->getAvailableStock() <= 0); + if ($itemsWithZeroStock->isNotEmpty()) { + $itemNames = $itemsWithZeroStock->pluck('name')->toArray(); + $warnings[] = "Single items with zero stock: " . implode(', ', $itemNames); + if ($throwOnWarnings) { + throw InvalidPoolConfigurationException::singleItemsWithZeroStock($this->name, $itemNames); + } + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * Validate booking product configuration and provide helpful error messages + * + * @throws InvalidBookingConfigurationException + */ + public function validateBookingConfiguration(bool $throwOnWarnings = false): array + { + $errors = []; + $warnings = []; + + if (!$this->isBooking()) { + throw InvalidBookingConfigurationException::notABookingProduct($this->name); + } + + // Critical: Stock management must be enabled + if (!$this->manage_stock) { + throw InvalidBookingConfigurationException::stockManagementNotEnabled($this->name); + } + + // Check for available stock + if ($this->getAvailableStock() <= 0) { + $warnings[] = "No stock available for booking"; + if ($throwOnWarnings) { + throw InvalidBookingConfigurationException::noStockAvailable($this->name); + } + } + + // Check for pricing + if (!$this->hasPrice()) { + $warnings[] = "No pricing configured"; + if ($throwOnWarnings) { + throw InvalidBookingConfigurationException::noPricingConfigured($this->name); + } + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * Validate product pricing configuration + * + * @throws HasNoPriceException + * @throws HasNoDefaultPriceException + */ + public function validatePricing(bool $throwExceptions = true): array + { + $errors = []; + $warnings = []; + + // Special handling for pool products + if ($this->isPool()) { + $hasDirectPrice = $this->prices()->exists(); + $singleItems = $this->singleProducts; + + if (!$hasDirectPrice) { + // Check if single items have prices to inherit + $singleItemsWithPrices = $singleItems->filter(function ($item) { + return $item->prices()->exists(); + }); + + if ($singleItemsWithPrices->isEmpty()) { + $errors[] = "Pool product has no pricing (direct or inherited)"; + if ($throwExceptions) { + throw HasNoPriceException::poolProductNoPriceAndNoSingleItemPrices($this->name); + } + } + } + + // If pool has direct prices, validate them + if ($hasDirectPrice) { + return $this->validateDirectPricing($throwExceptions); + } + + // Pool with inherited pricing is valid + return [ + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + // For all other product types, validate direct pricing + return $this->validateDirectPricing($throwExceptions); + } + + /** + * Validate direct pricing on the product + * + * @throws HasNoPriceException + * @throws HasNoDefaultPriceException + */ + protected function validateDirectPricing(bool $throwExceptions = true): array + { + $errors = []; + $warnings = []; + + $allPrices = $this->prices; + $priceCount = $allPrices->count(); + + // No prices at all + if ($priceCount === 0) { + $errors[] = "Product has no prices configured"; + if ($throwExceptions) { + throw HasNoPriceException::noPricesConfigured($this->name, $this->id); + } + + return [ + 'valid' => false, + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + $defaultPrices = $allPrices->where('is_default', true); + $defaultCount = $defaultPrices->count(); + + // Multiple default prices + if ($defaultCount > 1) { + $errors[] = "Product has {$defaultCount} default prices (should have exactly 1)"; + if ($throwExceptions) { + throw HasNoDefaultPriceException::multipleDefaultPrices($this->name, $defaultCount); + } + + return [ + 'valid' => false, + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + // No default price + if ($defaultCount === 0) { + if ($priceCount === 1) { + // Single price but not marked as default + $errors[] = "Product has one price but it's not marked as default"; + if ($throwExceptions) { + throw HasNoDefaultPriceException::onlyNonDefaultPriceExists($this->name); + } + } else { + // Multiple prices but none are default + $errors[] = "Product has {$priceCount} prices but none are marked as default"; + if ($throwExceptions) { + throw HasNoDefaultPriceException::multiplePricesNoDefault($this->name, $priceCount); + } + } + + return [ + 'valid' => false, + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + // Valid: Exactly one default price + return [ + 'valid' => true, + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * Get helpful setup instructions for pool products + */ + public static function getPoolSetupInstructions(): string + { + return <<<'INSTRUCTIONS' +# Pool Product Setup Guide + +Pool products aggregate multiple individual items (e.g., parking spots, hotel rooms) +into a single purchasable product where customers don't need to select specific items. + +## Step 1: Create the Pool Product + +```php +use Blax\Shop\Models\Product; +use Blax\Shop\Enums\ProductType; + +$pool = Product::create([ + 'type' => ProductType::POOL, + 'name' => 'Parking Lot', + 'slug' => 'parking-lot', +]); +``` + +## Step 2: Create Single Items (Booking Products) + +```php +$spot1 = Product::create([ + 'type' => ProductType::BOOKING, + 'name' => 'Parking Spot #1', + 'manage_stock' => true, +]); +$spot1->increaseStock(1); + +$spot2 = Product::create([ + 'type' => ProductType::BOOKING, + 'name' => 'Parking Spot #2', + 'manage_stock' => true, +]); +$spot2->increaseStock(1); +``` + +## Step 3: Link Single Items to Pool + +```php +use Blax\Shop\Enums\ProductRelationType; + +$pool->productRelations()->attach([ + $spot1->id => ['type' => ProductRelationType::SINGLE], + $spot2->id => ['type' => ProductRelationType::SINGLE], +]); +``` + +## Step 4: Set Pricing (Optional) + +```php +use Blax\Shop\Models\ProductPrice; + +// Option A: Set price on pool (takes precedence) +ProductPrice::create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // 50.00 per day + 'currency' => 'USD', + 'is_default' => true, +]); + +// Option B: Set prices on single items (pool inherits) +ProductPrice::create([ + 'purchasable_id' => $spot1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, +]); + +// Set pricing strategy (if using inheritance) +$pool->setPoolPricingStrategy('average'); // or 'lowest' or 'highest' +``` + +## Step 5: Add to Cart with Timespan + +```php +use Blax\Shop\Facades\Cart; +use Carbon\Carbon; + +Cart::addBooking( + $pool, + 2, // quantity + Carbon::parse('2025-01-15 09:00'), // from + Carbon::parse('2025-01-17 17:00'), // until +); +``` + +## Validation + +```php +// Validate configuration before use +$validation = $pool->validatePoolConfiguration(); +if (!$validation['valid']) { + foreach ($validation['errors'] as $error) { + echo "Error: $error\n"; + } +} +``` + +INSTRUCTIONS; + } + + /** + * Get helpful setup instructions for booking products + */ + public static function getBookingSetupInstructions(): string + { + return <<<'INSTRUCTIONS' +# Booking Product Setup Guide + +Booking products represent time-based reservations (conference rooms, equipment, etc.) + +## Step 1: Create the Booking Product + +```php +use Blax\Shop\Models\Product; +use Blax\Shop\Enums\ProductType; + +$product = Product::create([ + 'type' => ProductType::BOOKING, + 'name' => 'Conference Room A', + 'manage_stock' => true, // REQUIRED for bookings +]); +``` + +## Step 2: Set Initial Stock + +```php +// For single-unit bookings (1 room, 1 equipment piece, etc.) +$product->increaseStock(1); + +// For multiple units (e.g., 5 identical meeting rooms) +$product->increaseStock(5); +``` + +## Step 3: Configure Pricing + +```php +use Blax\Shop\Models\ProductPrice; + +ProductPrice::create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 10000, // Price per day in cents (100.00 USD) + 'currency' => 'USD', + 'is_default' => true, +]); +``` + +## Step 4: Add to Cart with Timespan + +```php +use Blax\Shop\Facades\Cart; +use Carbon\Carbon; + +Cart::addBooking( + $product, + 1, // quantity + Carbon::parse('2025-01-15 09:00'), // from + Carbon::parse('2025-01-17 17:00'), // until +); + +// Price will be: 100.00/day × 3 days = 300.00 +``` + +## Check Availability + +```php +$from = Carbon::parse('2025-01-15 09:00'); +$until = Carbon::parse('2025-01-17 17:00'); + +if ($product->isAvailableForBooking($from, $until, 1)) { + // Product is available for this period + Cart::addBooking($product, 1, $from, $until); +} +``` + +## Validation + +```php +// Validate configuration +$validation = $product->validateBookingConfiguration(); +if (!$validation['valid']) { + foreach ($validation['errors'] as $error) { + echo "Error: $error\n"; + } +} +``` + +INSTRUCTIONS; + } } diff --git a/src/Services/CartService.php b/src/Services/CartService.php index 5be1693..2955977 100644 --- a/src/Services/CartService.php +++ b/src/Services/CartService.php @@ -5,6 +5,13 @@ namespace Blax\Shop\Services; use Blax\Shop\Models\Cart; use Blax\Shop\Models\CartItem; use Blax\Shop\Contracts\Cartable; +use Blax\Shop\Enums\ProductType; +use Blax\Shop\Exceptions\HasNoDefaultPriceException; +use Blax\Shop\Exceptions\HasNoPriceException; +use Blax\Shop\Exceptions\InvalidBookingConfigurationException; +use Blax\Shop\Exceptions\InvalidPoolConfigurationException; +use Blax\Shop\Exceptions\NotPurchasable; +use Blax\Shop\Models\Product; use Illuminate\Database\Eloquent\Model; use Illuminate\Contracts\Auth\Authenticatable; @@ -80,6 +87,8 @@ class CartService * @param int $quantity * @param array $parameters * @return CartItem + * @throws HasNoPriceException + * @throws HasNoDefaultPriceException */ public function add(Model $product, int $quantity = 1, array $parameters = []): CartItem { @@ -89,6 +98,11 @@ class CartService throw new \Exception('No authenticated user found. Use guest() for guest carts.'); } + // Validate pricing before adding to cart + if ($product instanceof Product) { + $product->validatePricing(throwExceptions: true); + } + return $user->addToCart($product, $quantity, $parameters); } @@ -310,4 +324,195 @@ class CartService return $cart->getPaidAmount(); } + + /** + * Validate cart items for booking products + * Checks if all booking products have valid timespans and stock availability + * + * @param Cart|null $cart + * @return array Array of validation errors + * @throws \Exception + */ + public function validateBookings(?Cart $cart = null): array + { + if (!$cart) { + $cart = $this->current(); + } + + $errors = []; + + foreach ($cart->items as $item) { + $product = $item->purchasable; + + if (!$product instanceof Product) { + continue; + } + + // Check if booking product has timespan + if ($product->isBooking() && (!$item->from || !$item->until)) { + $errors[] = "Booking product '{$product->name}' requires a timespan (from/until dates)."; + continue; + } + + // Check if pool product with booking items has timespan + if ($product->isPool() && $product->hasBookingSingleItems()) { + // If pool has a timespan, validate it + if ($item->from && $item->until) { + // Check if quantity is available for the timespan + $maxQuantity = $product->getPoolMaxQuantity($item->from, $item->until); + if ($item->quantity > $maxQuantity) { + $errors[] = "Only {$maxQuantity} '{$product->name}' available for the selected period. You requested {$item->quantity}."; + } + } else { + // Check if individual single items have timespans in meta + $meta = $item->getMeta(); + $hasIndividualTimespans = $meta->individual_timespans ?? false; + + if (!$hasIndividualTimespans) { + $errors[] = "Pool product '{$product->name}' with booking items requires either a timespan or individual timespans for each item."; + } + } + } + + // Validate stock availability for booking period + if ($product->isBooking() && $item->from && $item->until) { + if (!$product->isAvailableForBooking($item->from, $item->until, $item->quantity)) { + $errors[] = "'{$product->name}' is not available for the selected period (insufficient stock)."; + } + } + } + + return $errors; + } + + /** + * Check if cart has valid bookings + * + * @param Cart|null $cart + * @return bool + * @throws \Exception + */ + public function hasValidBookings(?Cart $cart = null): bool + { + return empty($this->validateBookings($cart)); + } + + /** + * Add a booking product to cart with timespan + * + * @param Model&Cartable $product + * @param int $quantity + * @param \DateTimeInterface $from + * @param \DateTimeInterface $until + * @param array $parameters + * @return CartItem + * @throws HasNoPriceException + * @throws HasNoDefaultPriceException + * @throws InvalidBookingConfigurationException + * @throws InvalidPoolConfigurationException + * @throws NotPurchasable + */ + public function addBooking( + Model $product, + int $quantity, + \DateTimeInterface $from, + \DateTimeInterface $until, + array $parameters = [] + ): CartItem { + $user = auth()->user(); + + if (!$user) { + throw new \Exception('No authenticated user found. Use guest() for guest carts.'); + } + + // Validate timespan + if ($from >= $until) { + throw InvalidBookingConfigurationException::invalidTimespan($from, $until); + } + + if ($from->lessThan(now())) { + throw InvalidBookingConfigurationException::invalidTimespan($from, $until); + } + + // Validate the product type and configuration + if ($product instanceof Product) { + if (!$product->isBooking() && !$product->isPool()) { + throw new \Exception( + "Product '{$product->name}' is not a booking or pool type.\n\n" . + "For booking products:\n" . + Product::getBookingSetupInstructions() . "\n\n" . + "For pool products:\n" . + Product::getPoolSetupInstructions() + ); + } + + // Validate pricing before adding to cart + $product->validatePricing(throwExceptions: true); + + // Validate booking product configuration + if ($product->isBooking()) { + $product->validateBookingConfiguration(); + } + + // Validate pool product configuration + if ($product->isPool()) { + $product->validatePoolConfiguration(); + } + } // Check availability + if ($product instanceof Product && $product->isBooking()) { + if (!$product->isAvailableForBooking($from, $until, $quantity)) { + $available = $product->getAvailableStock(); + throw InvalidBookingConfigurationException::notAvailableForPeriod( + $product->name, + $from, + $until, + $quantity, + $available + ); + } + } + + // Check pool product availability + if ($product instanceof Product && $product->isPool()) { + $maxQuantity = $product->getPoolMaxQuantity($from, $until); + if ($quantity > $maxQuantity) { + throw InvalidPoolConfigurationException::notEnoughAvailableItems( + $product->name, + $from, + $until, + $quantity, + $maxQuantity + ); + } + } + + // Add to cart with timespan + $cart = $user->currentCart(); + + $pricePerDay = $product->getCurrentPrice(); + + // Calculate price based on days for booking products + if ($product instanceof Product && ($product->isBooking() || $product->isPool())) { + $days = $from->diff($until)->days; + $pricePerUnit = $pricePerDay * $days; // Price for one unit for the entire period + $totalPrice = $pricePerUnit * $quantity; // Total for all units + } else { + $pricePerUnit = $pricePerDay; + $totalPrice = $pricePerDay * $quantity; + } + + $cartItem = $cart->items()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => get_class($product), + 'quantity' => $quantity, + 'price' => $pricePerUnit, // Price per unit for the period + 'subtotal' => $totalPrice, // Total for all units + 'regular_price' => $pricePerDay, + 'parameters' => $parameters, + 'from' => $from, + 'until' => $until, + ]); + + return $cartItem; + } } diff --git a/src/Traits/HasCart.php b/src/Traits/HasCart.php index 3af72e1..f87ff4e 100644 --- a/src/Traits/HasCart.php +++ b/src/Traits/HasCart.php @@ -65,14 +65,20 @@ trait HasCart if ($product_or_price instanceof Product) { $product_or_price->claimStock($quantity); - $default_prices = $product_or_price->defaultPrice()->count(); + // Skip default price validation for pool products without direct prices + // (they inherit pricing from single items and are validated in validatePricing()) + $isPoolWithInheritedPricing = $product_or_price->isPool() && !$product_or_price->prices()->exists(); - if ($default_prices === 0) { - throw new NotPurchasable("Product has no default price"); - } + if (!$isPoolWithInheritedPricing) { + $default_prices = $product_or_price->defaultPrice()->count(); - if ($default_prices > 1) { - throw new MultiplePurchaseOptions("Product has multiple default prices, please specify a price to add to cart"); + if ($default_prices === 0) { + throw new NotPurchasable("Product has no default price"); + } + + if ($default_prices > 1) { + throw new MultiplePurchaseOptions("Product has multiple default prices, please specify a price to add to cart"); + } } } diff --git a/tests/Feature/BookingTimespanValidationTest.php b/tests/Feature/BookingTimespanValidationTest.php new file mode 100644 index 0000000..e2f5792 --- /dev/null +++ b/tests/Feature/BookingTimespanValidationTest.php @@ -0,0 +1,450 @@ +user = User::factory()->create(); + auth()->login($this->user); // Authenticate the user + + $this->bookingProduct = Product::factory()->create([ + 'name' => 'Hotel Room', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->bookingProduct->increaseStock(1); // Only 1 unit available for testing overlaps + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->bookingProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 10000, // 100.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + $this->cartService = new CartService(); + } + + /** @test */ + public function it_rejects_booking_when_from_is_after_until() + { + $from = now()->addDays(5); + $until = now()->addDays(2); + + $this->expectException(NotPurchasable::class); + $this->expectExceptionMessage("Invalid booking timespan"); + + $this->cartService->addBooking( + $this->bookingProduct, + 1, + $from, + $until + ); + } + + /** @test */ + public function it_rejects_booking_when_from_equals_until() + { + $from = now()->addDays(3); + $until = $from->copy(); + + $this->expectException(NotPurchasable::class); + $this->expectExceptionMessage("Invalid booking timespan"); + + $this->cartService->addBooking( + $this->bookingProduct, + 1, + $from, + $until + ); + } + + /** @test */ + public function it_rejects_booking_when_from_is_in_the_past() + { + $from = now()->subDays(2); + $until = now()->addDays(3); + + $this->expectException(NotPurchasable::class); + $this->expectExceptionMessage("Invalid booking timespan"); + + $this->cartService->addBooking( + $this->bookingProduct, + 1, + $from, + $until + ); + } + + /** @test */ + public function it_accepts_booking_when_from_is_exactly_now() + { + $from = now()->addSeconds(1); // Very slightly in the future to avoid timing issues + $until = now()->addDays(2); + + $cart = $this->user->currentCart(); + $this->cartService->addBooking($this->bookingProduct, 1, $from, $until); + + $this->assertCount(1, $cart->fresh()->items); + $cartItem = $cart->items->first(); + $this->assertNotNull($cartItem->from); + $this->assertNotNull($cartItem->until); + } + + /** @test */ + public function it_validates_timespan_availability_across_date_range() + { + // Create a pool product with 1 single item + $poolProduct = Product::factory()->create([ + 'name' => 'Kayak Fleet', + 'type' => ProductType::POOL, + ]); + + $singleItem = Product::factory()->create([ + 'name' => 'Kayak #1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $singleItem->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $singleItem->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $poolProduct->productRelations()->attach($singleItem->id, ['type' => ProductRelationType::SINGLE->value]); + + // Book the single item for days 2-4 + $existingFrom = now()->addDays(2)->startOfDay(); + $existingUntil = now()->addDays(4)->endOfDay(); + $this->cartService->addBooking( + $singleItem, + 1, + $existingFrom, + $existingUntil + ); + + $this->user->currentCart()->checkout(); + + // Try to book the pool for days 1-5 (overlaps with existing booking) + $newUser = User::factory()->create(); + auth()->login($newUser); + $newFrom = now()->addDays(1)->startOfDay(); + $newUntil = now()->addDays(5)->endOfDay(); + + $this->expectException(NotPurchasable::class); + $this->expectExceptionMessage('does not have enough available items'); + + $this->cartService->addBooking( + $poolProduct, + 1, + $newFrom, + $newUntil + ); + } + + /** @test */ + public function it_allows_back_to_back_bookings_without_overlap() + { + // Create a pool product with 2 single items so both bookings can succeed + $poolProduct = Product::factory()->create([ + 'type' => ProductType::POOL, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $singleItem1 = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $singleItem1->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $singleItem2 = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $singleItem2->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $poolProduct->productRelations()->attach($singleItem1->id, ['type' => ProductRelationType::SINGLE->value]); + $poolProduct->productRelations()->attach($singleItem2->id, ['type' => ProductRelationType::SINGLE->value]); + + // First booking: days 1-3 ending at 23:59:59 + $firstFrom = now()->addDays(1)->startOfDay(); + $firstUntil = now()->addDays(3)->endOfDay(); + $this->cartService->addBooking( + $singleItem1, + 1, + $firstFrom, + $firstUntil + ); + + $this->user->currentCart()->checkout(); + + // Second booking: days 4-6 starting at 00:00:00 + $newUser = User::factory()->create(); + auth()->login($newUser); + $secondFrom = now()->addDays(4)->startOfDay(); + $secondUntil = now()->addDays(6)->endOfDay(); + + $this->cartService->addBooking( + $poolProduct, + 1, + $secondFrom, + $secondUntil + ); + + $this->assertCount(1, $newUser->currentCart()->items); + } + + /** @test */ + public function it_detects_overlap_when_new_booking_ends_during_existing_booking() + { + // Book days 5-10 + $existingFrom = now()->addDays(5)->startOfDay(); + $existingUntil = now()->addDays(10)->endOfDay(); + $this->cartService->addBooking( + $this->bookingProduct, + 1, + $existingFrom, + $existingUntil + ); + + $this->user->currentCart()->checkout(); + + // Try to book days 3-7 (overlaps with existing) + $newUser = User::factory()->create(); + auth()->login($newUser); + $newFrom = now()->addDays(3)->startOfDay(); + $newUntil = now()->addDays(7)->endOfDay(); + + $this->expectException(NotPurchasable::class); + + $this->cartService->addBooking( + $this->bookingProduct, + 1, + $newFrom, + $newUntil + ); + } + + /** @test */ + public function it_detects_overlap_when_new_booking_starts_during_existing_booking() + { + // Book days 5-10 + $existingFrom = now()->addDays(5)->startOfDay(); + $existingUntil = now()->addDays(10)->endOfDay(); + $this->cartService->addBooking( + $this->bookingProduct, + 1, + $existingFrom, + $existingUntil + ); + + $this->user->currentCart()->checkout(); + + // Try to book days 8-12 (overlaps with existing) + $newUser = User::factory()->create(); + auth()->login($newUser); + $newFrom = now()->addDays(8)->startOfDay(); + $newUntil = now()->addDays(12)->endOfDay(); + + $this->expectException(NotPurchasable::class); + + $this->cartService->addBooking( + $this->bookingProduct, + 1, + $newFrom, + $newUntil + ); + } + + /** @test */ + public function it_detects_overlap_when_new_booking_completely_contains_existing_booking() + { + // Book days 6-8 + $existingFrom = now()->addDays(6)->startOfDay(); + $existingUntil = now()->addDays(8)->endOfDay(); + $this->cartService->addBooking( + $this->bookingProduct, + 1, + $existingFrom, + $existingUntil + ); + + $this->user->currentCart()->checkout(); + + // Try to book days 5-10 (completely contains existing) + $newUser = User::factory()->create(); + auth()->login($newUser); + $newFrom = now()->addDays(5)->startOfDay(); + $newUntil = now()->addDays(10)->endOfDay(); + + $this->expectException(NotPurchasable::class); + + $this->cartService->addBooking( + $this->bookingProduct, + 1, + $newFrom, + $newUntil + ); + } + + /** @test */ + public function it_detects_overlap_when_new_booking_is_completely_contained_by_existing_booking() + { + // Book days 5-10 + $existingFrom = now()->addDays(5)->startOfDay(); + $existingUntil = now()->addDays(10)->endOfDay(); + $this->cartService->addBooking( + $this->bookingProduct, + 1, + $existingFrom, + $existingUntil + ); + + $this->user->currentCart()->checkout(); + + // Try to book days 6-8 (completely contained by existing) + $newUser = User::factory()->create(); + auth()->login($newUser); + $newFrom = now()->addDays(6)->startOfDay(); + $newUntil = now()->addDays(8)->endOfDay(); + + $this->expectException(NotPurchasable::class); + + $this->cartService->addBooking( + $this->bookingProduct, + 1, + $newFrom, + $newUntil + ); + } + + /** @test */ + public function it_handles_timezone_aware_timespan_validation() + { + Carbon::setTestNow(now('America/New_York')); + + $from = now('America/New_York')->addDays(1); + $until = now('America/New_York')->addDays(3); + + $cart = $this->user->currentCart(); + $this->cartService->addBooking($this->bookingProduct, 1, $from, $until); + + $cartItem = $cart->fresh()->items->first(); + + // Verify dates are stored correctly + $this->assertNotNull($cartItem->from); + $this->assertNotNull($cartItem->until); + $this->assertTrue($cartItem->from->lessThan($cartItem->until)); + } + + /** @test */ + public function it_allows_same_product_multiple_non_overlapping_timespans() + { + // Create pool with 2 single items + $poolProduct = Product::factory()->create([ + 'type' => ProductType::POOL, + ]); + + $singleItem1 = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $singleItem1->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $singleItem2 = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $singleItem2->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $poolProduct->productRelations()->attach($singleItem1->id, ['type' => ProductRelationType::SINGLE->value]); + $poolProduct->productRelations()->attach($singleItem2->id, ['type' => ProductRelationType::SINGLE->value]); + + // Book first timespan (days 1-3) + $from1 = now()->addDays(1)->startOfDay(); + $until1 = now()->addDays(3)->endOfDay(); + $this->cartService->addBooking( + $poolProduct, + 1, + $from1, + $until1 + ); + + // Book second non-overlapping timespan (days 5-7) in same cart + $from2 = now()->addDays(5)->startOfDay(); + $until2 = now()->addDays(7)->endOfDay(); + $this->cartService->addBooking( + $poolProduct, + 1, + $from2, + $until2 + ); + + $this->assertCount(2, $this->user->currentCart()->items); + } +} diff --git a/tests/Feature/CartServiceBookingTest.php b/tests/Feature/CartServiceBookingTest.php new file mode 100644 index 0000000..60e89aa --- /dev/null +++ b/tests/Feature/CartServiceBookingTest.php @@ -0,0 +1,421 @@ +user = User::factory()->create(); + $this->actingAs($this->user); + + // Create booking product + $this->bookingProduct = Product::factory()->create([ + 'name' => 'Hotel Room', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->bookingProduct->increaseStock(10); + + $this->bookingPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $this->bookingProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 10000, // $100.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Create pool product with single items + $this->poolProduct = Product::factory()->create([ + 'name' => 'Parking Spaces', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $this->poolPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, // $20.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + $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); + + $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 validate_bookings_returns_error_for_booking_product_without_timespan() + { + $cart = $this->user->currentCart(); + + // Add booking product without timespan + $cart->items()->create([ + 'purchasable_id' => $this->bookingProduct->id, + 'purchasable_type' => Product::class, + 'quantity' => 1, + 'price' => 100.00, + ]); + + $errors = Cart::validateBookings(); + + $this->assertNotEmpty($errors); + $this->assertStringContainsString('requires a timespan', $errors[0]); + $this->assertStringContainsString('Hotel Room', $errors[0]); + } + + /** @test */ + public function validate_bookings_returns_error_for_pool_product_without_timespan_when_single_items_are_bookings() + { + $cart = $this->user->currentCart(); + + // Add pool product without timespan + $cart->items()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'quantity' => 1, + 'price' => 20.00, + ]); + + $errors = Cart::validateBookings(); + + $this->assertNotEmpty($errors); + $this->assertStringContainsString('requires either a timespan', $errors[0]); + $this->assertStringContainsString('Parking Spaces', $errors[0]); + } + + /** @test */ + public function validate_bookings_validates_stock_availability_correctly() + { + $cart = $this->user->currentCart(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Book all stock first + $this->bookingProduct->claimStock(10, null, $from, $until); + + // Try to add more than available + $cart->items()->create([ + 'purchasable_id' => $this->bookingProduct->id, + 'purchasable_type' => Product::class, + 'quantity' => 5, + 'price' => 100.00, + 'from' => $from, + 'until' => $until, + ]); + + $errors = Cart::validateBookings(); + + $this->assertNotEmpty($errors); + $this->assertStringContainsString('not available for the selected period', $errors[0]); + } + + /** @test */ + public function validate_bookings_handles_pool_products_with_individual_timespans_in_meta() + { + $cart = $this->user->currentCart(); + + // Add pool product with individual timespans flag + $cartItem = $cart->items()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'quantity' => 1, + 'price' => 20.00, + 'meta' => ['individual_timespans' => true], + ]); + + $errors = Cart::validateBookings(); + + // Should not have errors since individual timespans are marked + $this->assertEmpty($errors); + } + + /** @test */ + public function has_valid_bookings_returns_true_when_all_bookings_are_valid() + { + $cart = $this->user->currentCart(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cart->items()->create([ + 'purchasable_id' => $this->bookingProduct->id, + 'purchasable_type' => Product::class, + 'quantity' => 2, + 'price' => 100.00, + 'from' => $from, + 'until' => $until, + ]); + + $this->assertTrue(Cart::hasValidBookings()); + } + + /** @test */ + public function has_valid_bookings_returns_false_when_bookings_are_invalid() + { + $cart = $this->user->currentCart(); + + // Add booking without timespan + $cart->items()->create([ + 'purchasable_id' => $this->bookingProduct->id, + 'purchasable_type' => Product::class, + 'quantity' => 1, + 'price' => 100.00, + ]); + + $this->assertFalse(Cart::hasValidBookings()); + } + + /** @test */ + public function add_booking_successfully_adds_booking_product_with_timespan() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cartItem = Cart::addBooking($this->bookingProduct, 2, $from, $until); + + $this->assertNotNull($cartItem); + $this->assertEquals($this->bookingProduct->id, $cartItem->purchasable_id); + $this->assertEquals(2, $cartItem->quantity); + $this->assertEquals($from->format('Y-m-d H:i:s'), $cartItem->from->format('Y-m-d H:i:s')); + $this->assertEquals($until->format('Y-m-d H:i:s'), $cartItem->until->format('Y-m-d H:i:s')); + } + + /** @test */ + public function add_booking_successfully_adds_pool_product_with_timespan() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + $this->assertNotNull($cartItem); + $this->assertEquals($this->poolProduct->id, $cartItem->purchasable_id); + $this->assertEquals(1, $cartItem->quantity); + $this->assertEquals($from->format('Y-m-d H:i:s'), $cartItem->from->format('Y-m-d H:i:s')); + $this->assertEquals($until->format('Y-m-d H:i:s'), $cartItem->until->format('Y-m-d H:i:s')); + } + + /** @test */ + public function add_booking_calculates_price_correctly_based_on_days() + { + $from = Carbon::now()->addDays(1)->startOfDay(); + $until = Carbon::now()->addDays(4)->startOfDay(); // 3 days + $days = $from->diffInDays($until); + + $cartItem = Cart::addBooking($this->bookingProduct, 2, $from, $until); + + // Price should be: price_per_day (10000 cents = 100 dollars) × days (3) = 30000 cents per unit + // Total should be: 30000 × quantity (2) = 60000 cents + $expectedPricePerUnit = 10000 * $days; // 30000 cents + $expectedTotal = $expectedPricePerUnit * 2; // 60000 cents + + $this->assertEquals($expectedPricePerUnit, $cartItem->price); + $this->assertEquals($expectedTotal, $cartItem->subtotal); + } + + /** @test */ + public function add_booking_throws_exception_when_product_is_not_booking_or_pool_type() + { + $simpleProduct = Product::factory()->create([ + 'name' => 'Simple Product', + 'type' => ProductType::SIMPLE, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $simpleProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('not a booking or pool type'); + + Cart::addBooking($simpleProduct, 1, $from, $until); + } + + /** @test */ + public function add_booking_throws_exception_when_insufficient_stock_available_for_booking_period() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Claim all stock first + $this->bookingProduct->claimStock(10, null, $from, $until); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('is not available for the requested period'); + + Cart::addBooking($this->bookingProduct, 5, $from, $until); + } + + /** @test */ + public function add_booking_throws_exception_when_pool_quantity_exceeds_available_single_items() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Pool has only 2 single items, trying to book 5 + $this->expectException(\Exception::class); + $this->expectExceptionMessage('does not have enough available items'); + + Cart::addBooking($this->poolProduct, 5, $from, $until); + } + + /** @test */ + public function add_booking_creates_cart_item_with_correct_from_until_timestamps() + { + $from = Carbon::now()->addDays(5)->setTime(14, 30, 0); + $until = Carbon::now()->addDays(8)->setTime(10, 0, 0); + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + $this->assertEquals($from->format('Y-m-d H:i:s'), $cartItem->from->format('Y-m-d H:i:s')); + $this->assertEquals($until->format('Y-m-d H:i:s'), $cartItem->until->format('Y-m-d H:i:s')); + } + + /** @test */ + public function add_booking_stores_regular_price_correctly() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + $this->assertNotNull($cartItem->regular_price); + $this->assertEquals($this->bookingProduct->getCurrentPrice(), $cartItem->regular_price); + } + + /** @test */ + public function validate_bookings_returns_error_when_pool_quantity_exceeds_available_single_items() + { + $cart = $this->user->currentCart(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Pool has 2 single items, requesting 3 + $cart->items()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'quantity' => 3, + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + $errors = Cart::validateBookings(); + + $this->assertNotEmpty($errors); + $this->assertStringContainsString('2', $errors[0]); // Available count + $this->assertStringContainsString('3', $errors[0]); // Requested count + $this->assertStringContainsString('Parking Spaces', $errors[0]); + } + + /** @test */ + public function validate_bookings_passes_with_valid_pool_product_and_timespan() + { + $cart = $this->user->currentCart(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cart->items()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'quantity' => 2, // Exactly matches available single items + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + $errors = Cart::validateBookings(); + + $this->assertEmpty($errors); + } + + /** @test */ + public function validate_bookings_handles_multiple_booking_products_in_cart() + { + $cart = $this->user->currentCart(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Valid booking + $cart->items()->create([ + 'purchasable_id' => $this->bookingProduct->id, + 'purchasable_type' => Product::class, + 'quantity' => 2, + 'price' => 100.00, + 'from' => $from, + 'until' => $until, + ]); + + // Valid pool booking + $cart->items()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'quantity' => 1, + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + $errors = Cart::validateBookings(); + + $this->assertEmpty($errors); + } + + /** @test */ + public function add_booking_with_parameters_stores_them_correctly() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + $parameters = ['special_request' => 'Late checkout', 'vip' => true]; + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until, $parameters); + + $this->assertEquals($parameters, $cartItem->parameters); + } +} diff --git a/tests/Feature/PoolProductCheckoutTest.php b/tests/Feature/PoolProductCheckoutTest.php new file mode 100644 index 0000000..7e1a82e --- /dev/null +++ b/tests/Feature/PoolProductCheckoutTest.php @@ -0,0 +1,430 @@ +user = User::factory()->create(); + + // Create hotel room + $this->hotelRoom = Product::factory()->create([ + 'name' => 'Hotel Room', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->hotelRoom->increaseStock(5); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->hotelRoom->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 10000, + 'is_default' => true, + ]); + + // Create parking pool + $this->parkingPool = Product::factory()->create([ + 'name' => 'Parking Spaces', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'is_default' => true, + ]); + + // Create parking spots + $this->parkingSpot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->parkingSpot1->increaseStock(1); + + $this->parkingSpot2 = Product::factory()->create([ + 'name' => 'Spot 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->parkingSpot2->increaseStock(1); + + $this->parkingSpot3 = Product::factory()->create([ + 'name' => 'Spot 3', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->parkingSpot3->increaseStock(1); + + // Link spots to pool + foreach ([$this->parkingSpot1, $this->parkingSpot2, $this->parkingSpot3] as $spot) { + $this->parkingPool->productRelations()->attach($spot->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + } + } + + /** @test */ + public function checkout_cart_with_pool_product_claims_correct_single_items() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cart = $this->user->currentCart(); + $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 2, + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + $cart->checkout(); + + // Count claimed spots + $claimedCount = 0; + foreach ([$this->parkingSpot1, $this->parkingSpot2, $this->parkingSpot3] as $spot) { + if (!$spot->isAvailableForBooking($from, $until, 1)) { + $claimedCount++; + } + } + + $this->assertEquals(2, $claimedCount); + } + + /** @test */ + public function checkout_cart_with_pool_product_without_timespan_throws_exception_when_single_items_are_bookings() + { + $cart = $this->user->currentCart(); + + // Add pool product without timespan + $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 1, + 'price' => 20.00, + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('requires a timespan'); + + $cart->checkout(); + } + + /** @test */ + public function checkout_cart_with_pool_product_and_timespan_succeeds() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cart = $this->user->currentCart(); + $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 2, + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + $cart->checkout(); + + $this->assertTrue($cart->isConverted()); + $this->assertCount(1, $cart->purchases); + } + + /** @test */ + public function checkout_cart_with_pool_product_stores_claimed_items_in_cart_item_meta() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cart = $this->user->currentCart(); + $cartItem = $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 2, + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + $cart->checkout(); + + $cartItem->refresh(); + $meta = $cartItem->getMeta(); + $claimedItems = $meta->claimed_single_items ?? null; + + $this->assertNotNull($claimedItems); + $this->assertIsArray($claimedItems); + $this->assertCount(2, $claimedItems); + + // Verify claimed items are valid product IDs + foreach ($claimedItems as $itemId) { + $this->assertNotNull(Product::find($itemId)); + } + } + + /** @test */ + public function checkout_cart_with_multiple_pool_products_claims_from_each_independently() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Create second pool + $bikePool = Product::factory()->create([ + 'name' => 'Bike Rentals', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $bikePool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1500, + 'is_default' => true, + ]); + + $bike1 = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $bike1->increaseStock(1); + + $bike2 = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $bike2->increaseStock(1); + + $bikePool->productRelations()->attach($bike1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + $bikePool->productRelations()->attach($bike2->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + $cart = $this->user->currentCart(); + + // Add parking + $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 2, + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + // Add bikes + $cart->items()->create([ + 'purchasable_id' => $bikePool->id, + 'purchasable_type' => Product::class, + 'quantity' => 1, + 'price' => 15.00, + 'from' => $from, + 'until' => $until, + ]); + + $cart->checkout(); + + // Verify parking claims + $this->assertEquals(1, $this->parkingPool->getPoolMaxQuantity($from, $until)); + + // Verify bike claims + $this->assertEquals(1, $bikePool->getPoolMaxQuantity($from, $until)); + } + + /** @test */ + public function checkout_cart_with_pool_product_and_regular_booking_product_succeeds() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cart = $this->user->currentCart(); + + // Add hotel room + $cart->items()->create([ + 'purchasable_id' => $this->hotelRoom->id, + 'purchasable_type' => Product::class, + 'quantity' => 1, + 'price' => 100.00, + 'from' => $from, + 'until' => $until, + ]); + + // Add parking + $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 1, + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + $cart->checkout(); + + $this->assertTrue($cart->isConverted()); + $this->assertCount(2, $cart->purchases); + } + + /** @test */ + public function checkout_cart_with_pool_product_fails_when_single_item_becomes_unavailable_during_checkout() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cart = $this->user->currentCart(); + + // Add 3 parking spots (all available) + $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 3, + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + // Simulate another user booking spots before checkout + $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'); + + $cart->checkout(); + } + + /** @test */ + public function checkout_cart_validates_timespan_before_claiming_stock() + { + $cart = $this->user->currentCart(); + + // Add pool product without timespan + $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 1, + 'price' => 20.00, + // No from/until + ]); + + $this->expectException(\Exception::class); + + $cart->checkout(); + + // Verify no stock was claimed if validation failed + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + $this->assertEquals(3, $this->parkingPool->getPoolMaxQuantity($from, $until)); + } + + /** @test */ + public function checkout_creates_purchase_with_correct_timespan() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cart = $this->user->currentCart(); + $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 1, + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + $cart->checkout(); + + $purchase = ProductPurchase::where('cart_id', $cart->id)->first(); + + $this->assertNotNull($purchase); + $this->assertEquals($from->format('Y-m-d H:i:s'), $purchase->from->format('Y-m-d H:i:s')); + $this->assertEquals($until->format('Y-m-d H:i:s'), $purchase->until->format('Y-m-d H:i:s')); + } + + /** @test */ + public function checkout_with_pool_product_using_legacy_parameters() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cart = $this->user->currentCart(); + + // Use legacy parameters instead of from/until fields + $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 1, + 'price' => 20.00, + 'parameters' => [ + 'from' => $from->toDateTimeString(), + 'until' => $until->toDateTimeString(), + ], + ]); + + $cart->checkout(); + + $this->assertTrue($cart->isConverted()); + } + + /** @test */ + public function checkout_pool_product_claims_stock_with_cart_reference() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cart = $this->user->currentCart(); + $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 2, + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + $cart->checkout(); + + // Verify claims have cart as reference + $spot1Claim = $this->parkingSpot1->stocks() + ->where('reference_type', get_class($cart)) + ->where('reference_id', $cart->id) + ->first(); + + // At least one spot should have the cart as reference + $this->assertTrue( + $spot1Claim !== null || + $this->parkingSpot2->stocks()->where('reference_type', get_class($cart))->exists() || + $this->parkingSpot3->stocks()->where('reference_type', get_class($cart))->exists() + ); + } +} diff --git a/tests/Feature/PoolProductPricingTest.php b/tests/Feature/PoolProductPricingTest.php new file mode 100644 index 0000000..2f4361f --- /dev/null +++ b/tests/Feature/PoolProductPricingTest.php @@ -0,0 +1,427 @@ +user = User::factory()->create(); + auth()->login($this->user); + + // Create pool product + $this->poolProduct = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + ]); + + // Create single items + $this->singleItem1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->singleItem1->increaseStock(1); + + $this->singleItem2 = Product::factory()->create([ + 'name' => 'Spot 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->singleItem2->increaseStock(1); + + // 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 pool_product_inherits_price_from_single_items_when_no_pool_price_set() + { + // Set price on single items + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // 50.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // 50.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Pool product should inherit price + $price = $this->poolProduct->getCurrentPrice(); + + $this->assertNotNull($price); + $this->assertEquals(5000, $price); + } + + /** @test */ + public function pool_product_uses_own_price_when_explicitly_set() + { + // Set different prices on single items + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Set pool price + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 4500, // Discounted pool price + 'currency' => 'USD', + 'is_default' => true, + ]); + + $price = $this->poolProduct->getCurrentPrice(); + + $this->assertEquals(4500, $price); + } + + /** @test */ + public function pool_product_inherits_average_price_from_single_items_with_different_prices() + { + // Set different prices on single items + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 7000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Pool should inherit average: (5000 + 7000) / 2 = 6000 + $price = $this->poolProduct->getCurrentPrice(); + + $this->assertEquals(6000, $price); + } + + /** @test */ + public function pool_product_returns_null_when_no_prices_available() + { + // No prices set on pool or single items + $price = $this->poolProduct->getCurrentPrice(); + + $this->assertNull($price); + } + + /** @test */ + public function pool_product_inherits_lowest_price_from_single_items() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 7000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $lowestPrice = $this->poolProduct->getLowestPoolPrice(); + + $this->assertEquals(5000, $lowestPrice); + } + + /** @test */ + public function pool_product_inherits_highest_price_from_single_items() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 7000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $highestPrice = $this->poolProduct->getHighestPoolPrice(); + + $this->assertEquals(7000, $highestPrice); + } + + /** @test */ + public function pool_product_bulk_discount_applied_for_multiple_items() + { + // Set pool price with bulk discount + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Add to cart with quantity 2 + $cart = $this->user->currentCart(); + $cart->items()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'quantity' => 2, + 'price' => 5000 * 2, // 2 days + 'subtotal' => 5000 * 2 * 2, // 2 items × 2 days × 5000 + 'from' => $from, + 'until' => $until, + ]); + + $total = $cart->getTotal(); + + $this->assertEquals(20000, $total); + } + + /** @test */ + public function pool_product_pricing_strategy_can_be_set_to_average() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 7000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Set pricing strategy to average + $this->poolProduct->setPoolPricingStrategy('average'); + $price = $this->poolProduct->getCurrentPrice(); + + $this->assertEquals(6000, $price); + } + + /** @test */ + public function pool_product_pricing_strategy_can_be_set_to_lowest() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 7000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Set pricing strategy to lowest + $this->poolProduct->setPoolPricingStrategy('lowest'); + $price = $this->poolProduct->getCurrentPrice(); + + $this->assertEquals(5000, $price); + } + + /** @test */ + public function pool_product_pricing_strategy_can_be_set_to_highest() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 7000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Set pricing strategy to highest + $this->poolProduct->setPoolPricingStrategy('highest'); + $price = $this->poolProduct->getCurrentPrice(); + + $this->assertEquals(7000, $price); + } + + /** @test */ + public function pool_product_price_range_returns_min_and_max() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 7000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $priceRange = $this->poolProduct->getPoolPriceRange(); + + $this->assertEquals(['min' => 5000, 'max' => 7000], $priceRange); + } + + /** @test */ + public function pool_product_with_sale_price_applies_discount() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 10000, + 'sale_unit_amount' => 8000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Set sale period to make product on sale + $this->poolProduct->update([ + 'sale_start' => now()->subDay(), + 'sale_end' => now()->addDay(), + ]); + + $price = $this->poolProduct->getCurrentPrice(true); + + $this->assertEquals(8000, $price); + } + + /** @test */ + public function pool_product_ignores_single_items_without_prices() + { + // Only set price on one item + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Item 2 has no price + $price = $this->poolProduct->getCurrentPrice(); + + $this->assertEquals(5000, $price); + } + + /** @test */ + public function pool_product_pricing_updates_when_single_item_prices_change() + { + $price1 = ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $initialPrice = $this->poolProduct->getCurrentPrice(); + $this->assertEquals(5000, $initialPrice); + + // Update single item price + $price1->update(['unit_amount' => 6000]); + $this->poolProduct->refresh(); + + $updatedPrice = $this->poolProduct->getCurrentPrice(); + $this->assertEquals(5500, $updatedPrice); // Average of 6000 and 5000 + } + + /** @test */ + public function pool_product_with_custom_pricing_strategy_in_meta() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 7000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Store strategy in metadata + $this->poolProduct->updateMetaKey('pricing_strategy', 'lowest'); + + $price = $this->poolProduct->getCurrentPrice(); + + $this->assertEquals(5000, $price); + } +} diff --git a/tests/Feature/PoolProductTest.php b/tests/Feature/PoolProductTest.php new file mode 100644 index 0000000..a170267 --- /dev/null +++ b/tests/Feature/PoolProductTest.php @@ -0,0 +1,611 @@ +user = User::factory()->create(); + + // Create hotel room (booking product) + $this->hotelRoom = Product::factory()->create([ + 'name' => 'Hotel Room', + 'slug' => 'hotel-room', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->hotelRoom->increaseStock(5); + + $this->hotelPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $this->hotelRoom->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 10000, // $100.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Create parking pool product + $this->parkingPool = Product::factory()->create([ + 'name' => 'Parking Spaces', + 'slug' => 'parking-spaces', + 'type' => ProductType::POOL, + 'manage_stock' => false, // Pool doesn't manage its own stock + ]); + + $this->parkingPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, // $20.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Create individual parking spots (booking products with stock = 1) + $this->parkingSpot1 = Product::factory()->create([ + 'name' => 'Parking Spot 1', + 'slug' => 'parking-spot-1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->parkingSpot1->increaseStock(1); + + $this->parkingSpot2 = Product::factory()->create([ + 'name' => 'Parking Spot 2', + 'slug' => 'parking-spot-2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->parkingSpot2->increaseStock(1); + + $this->parkingSpot3 = Product::factory()->create([ + 'name' => 'Parking Spot 3', + 'slug' => 'parking-spot-3', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->parkingSpot3->increaseStock(1); + + // Link parking spots as SINGLE items to the pool + $this->parkingPool->productRelations()->attach($this->parkingSpot1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + $this->parkingPool->productRelations()->attach($this->parkingSpot2->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + $this->parkingPool->productRelations()->attach($this->parkingSpot3->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + // Link parking pool as cross-sell to hotel room + $this->hotelRoom->productRelations()->attach($this->parkingPool->id, [ + 'type' => ProductRelationType::CROSS_SELL->value, + ]); + } + + /** @test */ + public function it_can_create_a_pool_product() + { + $this->assertNotNull($this->parkingPool); + $this->assertEquals(ProductType::POOL, $this->parkingPool->type); + $this->assertTrue($this->parkingPool->isPool()); + } + + /** @test */ + public function pool_product_has_single_items_linked() + { + $singleItems = $this->parkingPool->singleProducts; + + $this->assertCount(3, $singleItems); + $this->assertTrue($singleItems->contains($this->parkingSpot1)); + $this->assertTrue($singleItems->contains($this->parkingSpot2)); + $this->assertTrue($singleItems->contains($this->parkingSpot3)); + } + + /** @test */ + public function pool_product_max_quantity_equals_number_of_single_items() + { + $maxQuantity = $this->parkingPool->getPoolMaxQuantity(); + + $this->assertEquals(3, $maxQuantity); + } + + /** @test */ + public function pool_product_detects_booking_single_items() + { + $this->assertTrue($this->parkingPool->hasBookingSingleItems()); + } + + /** @test */ + public function it_can_add_pool_product_to_cart_with_timespan() + { + $cart = $this->user->currentCart(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cartItem = $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 2, + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + $this->assertNotNull($cartItem); + $this->assertEquals($from->format('Y-m-d H:i:s'), $cartItem->from->format('Y-m-d H:i:s')); + $this->assertEquals($until->format('Y-m-d H:i:s'), $cartItem->until->format('Y-m-d H:i:s')); + } + + /** @test */ + public function pool_product_quantity_is_limited_by_available_single_items() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // All 3 parking spots are available + $maxQuantity = $this->parkingPool->getPoolMaxQuantity($from, $until); + $this->assertEquals(3, $maxQuantity); + + // Book one parking spot directly + $this->parkingSpot1->claimStock(1, null, $from, $until); + + // Now only 2 should be available in the pool + $maxQuantity = $this->parkingPool->getPoolMaxQuantity($from, $until); + $this->assertEquals(2, $maxQuantity); + } + + /** @test */ + public function booking_price_is_calculated_based_on_timespan_and_quantity() + { + $from = Carbon::now()->addDays(1)->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); + $days = $from->diffInDays($until); + + $quantity = 2; + $pricePerDay = 20.00; + $expectedTotal = $days * $quantity * $pricePerDay; + + // This would be $2 days * 2 parking spaces * $20 = $80 + $this->assertEquals(80.00, $expectedTotal); + } + + /** @test */ + public function pool_product_with_overlapping_bookings_reduces_available_quantity() + { + $from = Carbon::now()->addDays(5); + $until = Carbon::now()->addDays(7); + + // Initially all 3 spots available + $this->assertEquals(3, $this->parkingPool->getPoolMaxQuantity($from, $until)); + + // Book 2 spots + $this->parkingSpot1->claimStock(1, null, $from, $until); + $this->parkingSpot2->claimStock(1, null, $from, $until); + + // Only 1 spot should remain available + $this->assertEquals(1, $this->parkingPool->getPoolMaxQuantity($from, $until)); + } + + /** @test */ + public function different_timespan_bookings_dont_conflict() + { + $from1 = Carbon::now()->addDays(1); + $until1 = Carbon::now()->addDays(3); + + $from2 = Carbon::now()->addDays(5); + $until2 = Carbon::now()->addDays(7); + + // Book spot 1 for first period + $this->parkingSpot1->claimStock(1, null, $from1, $until1); + + // All spots should be available for second period + $this->assertEquals(3, $this->parkingPool->getPoolMaxQuantity($from2, $until2)); + + // Only 2 spots should be available for first period + $this->assertEquals(2, $this->parkingPool->getPoolMaxQuantity($from1, $until1)); + } + + /** @test */ + public function pool_product_unavailable_when_all_single_items_booked() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Book all 3 spots + $this->parkingSpot1->claimStock(1, null, $from, $until); + $this->parkingSpot2->claimStock(1, null, $from, $until); + $this->parkingSpot3->claimStock(1, null, $from, $until); + + // No spots should be available + $this->assertEquals(0, $this->parkingPool->getPoolMaxQuantity($from, $until)); + } + + /** @test */ + public function pool_product_can_be_cross_sell_of_hotel_room() + { + $crossSells = $this->hotelRoom->crossSellProducts; + + $this->assertCount(1, $crossSells); + $this->assertTrue($crossSells->contains($this->parkingPool)); + } + + /** @test */ + public function booking_cancellation_releases_stock_of_single_items() + { + $from = Carbon::now()->addDays(10); + $until = Carbon::now()->addDays(12); + + // Book a spot + $this->parkingSpot1->claimStock(1, null, $from, $until); + + // Should have 2 spots available + $this->assertEquals(2, $this->parkingPool->getPoolMaxQuantity($from, $until)); + + // Release the stock (simulate cancellation before booking starts) + $claim = $this->parkingSpot1->stocks() + ->where('type', StockType::CLAIMED->value) + ->where('claimed_from', $from) + ->where('expires_at', $until) + ->first(); + + if ($claim) { + $claim->release(); // Use the release method instead of delete + } + + // Should have 3 spots available again + $this->parkingSpot1->refresh(); + $this->assertEquals(3, $this->parkingPool->getPoolMaxQuantity($from, $until)); + } + + /** @test */ + public function pool_product_respects_partial_overlapping_bookings() + { + // Booking 1: Days 1-3 + $from1 = Carbon::now()->addDays(1); + $until1 = Carbon::now()->addDays(3); + + // Booking 2: Days 2-4 (overlaps with booking 1 on day 2) + $from2 = Carbon::now()->addDays(2); + $until2 = Carbon::now()->addDays(4); + + // Book spot 1 for days 1-3 + $this->parkingSpot1->claimStock(1, null, $from1, $until1); + + // For days 2-4, spot 1 should not be available (overlaps) + // So only 2 spots should be available + $this->assertEquals(2, $this->parkingPool->getPoolMaxQuantity($from2, $until2)); + } + + /** @test */ + public function multiple_pool_products_can_exist_independently() + { + // Create a second pool for bikes + $bikePool = Product::factory()->create([ + 'name' => 'Bike Rentals', + 'slug' => 'bike-rentals', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $bike1 = Product::factory()->create([ + 'name' => 'Bike 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $bike1->increaseStock(1); + + $bike2 = Product::factory()->create([ + 'name' => 'Bike 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $bike2->increaseStock(1); + + $bikePool->productRelations()->attach($bike1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + $bikePool->productRelations()->attach($bike2->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + // Both pools should work independently + $this->assertEquals(3, $this->parkingPool->getPoolMaxQuantity()); + $this->assertEquals(2, $bikePool->getPoolMaxQuantity()); + } + + /** @test */ + public function pool_product_stock_calculated_correctly_with_mixed_availability() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Spot 1: Fully booked for the period + $this->parkingSpot1->claimStock(1, null, $from, $until); + + // Spot 2: Available + // Spot 3: Booked for a different period + $otherFrom = Carbon::now()->addDays(5); + $otherUntil = Carbon::now()->addDays(7); + $this->parkingSpot3->claimStock(1, null, $otherFrom, $otherUntil); + + // For the requested period (days 1-3), spots 2 and 3 should be available + $this->assertEquals(2, $this->parkingPool->getPoolMaxQuantity($from, $until)); + } + + /** @test */ + public function pool_product_with_zero_single_items_returns_zero_max_quantity() + { + $emptyPool = Product::factory()->create([ + 'name' => 'Empty Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $this->assertEquals(0, $emptyPool->getPoolMaxQuantity()); + } + + /** @test */ + public function pool_product_with_non_booking_single_items_doesnt_require_timespan() + { + $simplePool = Product::factory()->create([ + 'name' => 'Simple Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $simpleItem = Product::factory()->create([ + 'name' => 'Simple Item', + 'type' => ProductType::SIMPLE, + 'manage_stock' => false, + ]); + + $simplePool->productRelations()->attach($simpleItem->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + $this->assertFalse($simplePool->hasBookingSingleItems()); + } + + /** @test */ + public function pool_product_with_mixed_booking_and_non_booking_single_items() + { + $mixedPool = Product::factory()->create([ + 'name' => 'Mixed Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $bookingItem = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $bookingItem->increaseStock(1); + + $simpleItem = Product::factory()->create([ + 'type' => ProductType::SIMPLE, + 'manage_stock' => false, + ]); + + $mixedPool->productRelations()->attach($bookingItem->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + $mixedPool->productRelations()->attach($simpleItem->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + // Should detect booking items exist + $this->assertTrue($mixedPool->hasBookingSingleItems()); + $this->assertEquals(2, $mixedPool->getPoolMaxQuantity()); + } + + /** @test */ + public function pool_product_checkout_claims_exactly_the_right_number_of_single_items() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cart = $this->user->currentCart(); + $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 2, + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + $cart->checkout(); + + // Verify 2 single items were claimed + $claimedCount = 0; + foreach ($this->parkingPool->singleProducts as $spot) { + $claims = $spot->stocks() + ->where('type', StockType::CLAIMED->value) + ->where('claimed_from', $from) + ->count(); + $claimedCount += $claims; + } + + $this->assertEquals(2, $claimedCount); + } + + /** @test */ + public function pool_product_checkout_stores_claimed_single_items_in_metadata() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cart = $this->user->currentCart(); + $cartItem = $cart->items()->create([ + 'purchasable_id' => $this->parkingPool->id, + 'purchasable_type' => Product::class, + 'quantity' => 2, + 'price' => 20.00, + 'from' => $from, + 'until' => $until, + ]); + + $cart->checkout(); + + $cartItem->refresh(); + $meta = $cartItem->getMeta(); + $claimedItems = $meta->claimed_single_items ?? null; + + $this->assertNotNull($claimedItems); + $this->assertIsArray($claimedItems); + $this->assertCount(2, $claimedItems); + } + + /** @test */ + public function pool_product_with_different_stock_quantities_on_single_items() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Create a spot with stock of 2 + $doubleSpot = Product::factory()->create([ + 'name' => 'Double Parking Spot', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $doubleSpot->increaseStock(2); + + $customPool = Product::factory()->create([ + 'name' => 'Custom Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $customPool->productRelations()->attach($doubleSpot->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + // Should still count as 1 item (not based on stock quantity) + $this->assertEquals(1, $customPool->getPoolMaxQuantity($from, $until)); + } + + /** @test */ + public function claim_pool_stock_throws_exception_when_not_enough_single_items_available() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Claim 2 spots first + $this->parkingSpot1->claimStock(1, null, $from, $until); + $this->parkingSpot2->claimStock(1, null, $from, $until); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('available'); + + // Try to claim 2 more (only 1 available) + $this->parkingPool->claimPoolStock(2, null, $from, $until); + } + + /** @test */ + public function claim_pool_stock_throws_exception_when_called_on_non_pool_product() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('only for pool products'); + + $this->hotelRoom->claimPoolStock(1, null, $from, $until); + } + + /** @test */ + public function release_pool_stock_correctly_releases_all_claims() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $reference = $this->user->currentCart(); + + // Claim 2 spots + $this->parkingPool->claimPoolStock(2, $reference, $from, $until); + + // Verify they're claimed + $this->assertEquals(1, $this->parkingPool->getPoolMaxQuantity($from, $until)); + + // Release them + $released = $this->parkingPool->releasePoolStock($reference); + + $this->assertEquals(2, $released); + $this->assertEquals(3, $this->parkingPool->getPoolMaxQuantity($from, $until)); + } + + /** @test */ + public function release_pool_stock_throws_exception_when_called_on_non_pool_product() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('only for pool products'); + + $this->hotelRoom->releasePoolStock($this->user->currentCart()); + } + + /** @test */ + public function pool_product_with_single_item_already_claimed_for_entire_period() + { + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(5); + + // Claim spot 1 for the entire period + $this->parkingSpot1->claimStock(1, null, $from, $until); + + // Should still have 2 spots available + $this->assertEquals(2, $this->parkingPool->getPoolMaxQuantity($from, $until)); + + // Claim spot 2 for part of the period + $partialFrom = Carbon::now()->addDays(2); + $partialUntil = Carbon::now()->addDays(4); + $this->parkingSpot2->claimStock(1, null, $partialFrom, $partialUntil); + + // For the entire period, only spot 3 should be available + $this->assertEquals(1, $this->parkingPool->getPoolMaxQuantity($from, $until)); + + // For the partial period, spot 3 should still be available (spot 1 is busy) + $this->assertEquals(1, $this->parkingPool->getPoolMaxQuantity($partialFrom, $partialUntil)); + } + + /** @test */ + public function pool_product_maximum_quantity_with_edge_of_timespan() + { + // Claim 1: Days 1-3 + $claim1From = Carbon::now()->addDays(1)->startOfDay(); + $claim1Until = Carbon::now()->addDays(3)->endOfDay(); + + // Claim 2: Days 3-5 (overlaps on day 3) + $claim2From = Carbon::now()->addDays(3)->startOfDay(); + $claim2Until = Carbon::now()->addDays(5)->endOfDay(); + + $this->parkingSpot1->claimStock(1, null, $claim1From, $claim1Until); + + // For days 3-5, spot 1 should still be unavailable due to overlap + $this->assertEquals(2, $this->parkingPool->getPoolMaxQuantity($claim2From, $claim2Until)); + } +} diff --git a/tests/Feature/ProductPricingValidationTest.php b/tests/Feature/ProductPricingValidationTest.php new file mode 100644 index 0000000..295049d --- /dev/null +++ b/tests/Feature/ProductPricingValidationTest.php @@ -0,0 +1,317 @@ +user = User::factory()->create(); + auth()->login($this->user); + } + + /** @test */ + public function it_throws_exception_when_product_has_no_prices() + { + $product = Product::factory()->create([ + 'name' => 'No Price Product', + 'type' => ProductType::SIMPLE, + ]); + + $this->expectException(HasNoPriceException::class); + $this->expectExceptionMessage('has no pricing configured'); + + Cart::add($product, 1); + } + + /** @test */ + public function it_throws_exception_when_product_has_multiple_prices_but_no_default() + { + $product = Product::factory()->create([ + 'name' => 'Multi Price Product', + 'type' => ProductType::SIMPLE, + ]); + + // Create multiple prices, none marked as default + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'is_default' => false, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 7000, + 'is_default' => false, + ]); + + $this->expectException(HasNoDefaultPriceException::class); + $this->expectExceptionMessage('none are marked as default'); + + Cart::add($product, 1); + } + + /** @test */ + public function it_throws_exception_when_product_has_single_price_not_marked_as_default() + { + $product = Product::factory()->create([ + 'name' => 'Single Non-Default Price', + 'type' => ProductType::SIMPLE, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'is_default' => false, // Not marked as default + ]); + + $this->expectException(HasNoDefaultPriceException::class); + $this->expectExceptionMessage("not marked as default"); + + Cart::add($product, 1); + } + + /** @test */ + public function it_throws_exception_when_product_has_multiple_default_prices() + { + $product = Product::factory()->create([ + 'name' => 'Multiple Defaults', + 'type' => ProductType::SIMPLE, + ]); + + // Create multiple prices, all marked as default + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 7000, + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 9000, + 'is_default' => true, + ]); + + $this->expectException(HasNoDefaultPriceException::class); + $this->expectExceptionMessage('3 prices marked as default'); + + Cart::add($product, 1); + } + + /** @test */ + public function it_allows_adding_product_with_valid_default_price() + { + $product = Product::factory()->create([ + 'name' => 'Valid Product', + 'type' => ProductType::SIMPLE, + 'manage_stock' => true, + ]); + $product->increaseStock(10); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'is_default' => true, + ]); + + $cartItem = Cart::add($product, 1); + + $this->assertNotNull($cartItem); + $this->assertEquals($product->id, $cartItem->purchasable_id); + } + + /** @test */ + public function it_allows_product_with_one_default_and_multiple_non_default_prices() + { + $product = Product::factory()->create([ + 'name' => 'Mixed Prices', + 'type' => ProductType::SIMPLE, + 'manage_stock' => true, + ]); + $product->increaseStock(10); + + // One default price + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'is_default' => true, + ]); + + // Multiple non-default prices + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 4500, + 'is_default' => false, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 4000, + 'is_default' => false, + ]); + + $cartItem = Cart::add($product, 1); + + $this->assertNotNull($cartItem); + $this->assertEquals($product->id, $cartItem->purchasable_id); + } + + /** @test */ + public function it_throws_exception_when_pool_has_no_price_and_single_items_have_no_prices() + { + $pool = Product::factory()->create([ + 'name' => 'Pool Without Prices', + 'type' => ProductType::POOL, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + $pool->productRelations()->attach($spot1->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + + $this->expectException(HasNoPriceException::class); + $this->expectExceptionMessage('Pool product'); + $this->expectExceptionMessage('has no pricing configured'); + + Cart::add($pool, 1); + } + + /** @test */ + public function it_allows_pool_with_no_direct_price_but_single_items_have_prices() + { + $pool = Product::factory()->create([ + 'name' => 'Pool With Inherited Prices', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $spot1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'is_default' => true, + ]); + + $pool->productRelations()->attach($spot1->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + + $cartItem = Cart::add($pool, 1); + + $this->assertNotNull($cartItem); + $this->assertEquals($pool->id, $cartItem->purchasable_id); + } + + /** @test */ + public function it_allows_pool_with_direct_price_even_if_single_items_have_no_prices() + { + $pool = Product::factory()->create([ + 'name' => 'Pool With Direct Price', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 6000, + 'is_default' => true, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + $pool->productRelations()->attach($spot1->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + + $cartItem = Cart::add($pool, 1); + + $this->assertNotNull($cartItem); + $this->assertEquals($pool->id, $cartItem->purchasable_id); + } + + /** @test */ + public function validate_pricing_returns_errors_array_without_throwing() + { + $product = Product::factory()->create([ + 'name' => 'Test Product', + 'type' => ProductType::SIMPLE, + ]); + + $result = $product->validatePricing(throwExceptions: false); + + $this->assertFalse($result['valid']); + $this->assertNotEmpty($result['errors']); + $this->assertStringContainsString('no prices', $result['errors'][0]); + } + + /** @test */ + public function validate_pricing_with_valid_price_returns_valid() + { + $product = Product::factory()->create([ + 'name' => 'Valid Product', + 'type' => ProductType::SIMPLE, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'is_default' => true, + ]); + + $result = $product->validatePricing(throwExceptions: false); + + $this->assertTrue($result['valid']); + $this->assertEmpty($result['errors']); + } +}