A booking/pool procedures & tests, IA tests, exceptions

This commit is contained in:
Fabian @ Blax Software 2025-12-15 11:32:31 +01:00
parent cabae43950
commit 67917e6a31
18 changed files with 4107 additions and 185 deletions

View File

@ -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);
```

View File

@ -282,6 +282,8 @@ return new class extends Migration
$table->decimal('subtotal', 10, 2); $table->decimal('subtotal', 10, 2);
$table->json('parameters')->nullable(); $table->json('parameters')->nullable();
$table->json('meta')->nullable(); $table->json('meta')->nullable();
$table->timestamp('from')->nullable();
$table->timestamp('until')->nullable();
$table->timestamps(); $table->timestamps();
$table->index(['cart_id', 'purchasable_id']); $table->index(['cart_id', 'purchasable_id']);

View File

@ -10,6 +10,7 @@ enum ProductType: string
case EXTERNAL = 'external'; case EXTERNAL = 'external';
case BOOKING = 'booking'; case BOOKING = 'booking';
case VARIATION = 'variation'; case VARIATION = 'variation';
case POOL = 'pool';
public function label(): string public function label(): string
{ {
@ -20,6 +21,7 @@ enum ProductType: string
self::EXTERNAL => 'External', self::EXTERNAL => 'External',
self::BOOKING => 'Booking', self::BOOKING => 'Booking',
self::VARIATION => 'Variation', self::VARIATION => 'Variation',
self::POOL => 'Pool',
}; };
} }
} }

View File

@ -0,0 +1,85 @@
<?php
namespace Blax\Shop\Exceptions;
class HasNoDefaultPriceException extends NotPurchasable
{
public static function multiplePricesNoDefault(string $productName, int $priceCount): self
{
return new self(
"Product '{$productName}' has {$priceCount} prices configured but none are marked as default.\n\n" .
"When a product has multiple prices, one must be marked as the default price.\n\n" .
"To fix this, update one of the existing prices:\n\n" .
"use Blax\Shop\Models\ProductPrice;\n\n" .
"// Option 1: Update existing price to be default\n" .
"\$price = ProductPrice::where('purchasable_id', \$product->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"
);
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace Blax\Shop\Exceptions;
class HasNoPriceException extends NotPurchasable
{
public static function noPricesConfigured(string $productName, string $productId): self
{
return new self(
"Product '{$productName}' has no pricing configured and cannot be added to cart.\n\n" .
"To add pricing:\n\n" .
"use Blax\Shop\Models\ProductPrice;\n\n" .
"ProductPrice::create([\n" .
" 'purchasable_id' => '{$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"
);
}
}

View File

@ -0,0 +1,158 @@
<?php
namespace Blax\Shop\Exceptions;
class InvalidBookingConfigurationException extends NotPurchasable
{
public static function notABookingProduct(string $productName): self
{
return new self(
"Product '{$productName}' is not a booking product. " .
"To create a booking product:\n\n" .
"1. Set the product type:\n" .
"Product::create([\n" .
" 'type' => 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"
);
}
}

View File

@ -0,0 +1,162 @@
<?php
namespace Blax\Shop\Exceptions;
class InvalidPoolConfigurationException extends NotPurchasable
{
public static function notAPoolProduct(string $productName): self
{
return new self(
"Product '{$productName}' is not a pool product. " .
"To create a pool product:\n" .
"1. Set type to ProductType::POOL\n" .
"2. Add single items using: \$pool->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" .
"}"
);
}
}

View File

@ -248,21 +248,56 @@ class Cart extends Model
$product = $item->purchasable; $product = $item->purchasable;
$quantity = $item->quantity; $quantity = $item->quantity;
// Extract booking dates from parameters if this is a booking product // Get booking dates from cart item directly (preferred) or from parameters (legacy)
$from = null; $from = $item->from;
$until = null; $until = $item->until;
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;
// Convert to Carbon instances if they're strings if (!$from || !$until) {
if ($from && is_string($from)) { if (($product->type === ProductType::BOOKING || $product->type === ProductType::POOL) && $item->parameters) {
$from = \Carbon\Carbon::parse($from); $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( $purchase = $this->customer->purchase(

View File

@ -22,6 +22,8 @@ class CartItem extends Model
'parameters', 'parameters',
'purchase_id', 'purchase_id',
'meta', 'meta',
'from',
'until',
]; ];
protected $casts = [ protected $casts = [
@ -31,6 +33,8 @@ class CartItem extends Model
'subtotal' => 'decimal:2', 'subtotal' => 'decimal:2',
'parameters' => 'array', 'parameters' => 'array',
'meta' => 'array', 'meta' => 'array',
'from' => 'datetime',
'until' => 'datetime',
]; ];
public function __construct(array $attributes = []) public function __construct(array $attributes = [])

View File

@ -11,6 +11,10 @@ use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType; 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\HasCategories;
use Blax\Shop\Traits\HasPrices; use Blax\Shop\Traits\HasPrices;
use Blax\Shop\Traits\HasProductRelations; use Blax\Shop\Traits\HasProductRelations;
@ -312,6 +316,148 @@ class Product extends Model implements Purchasable, Cartable
return $this->type === ProductType::BOOKING; 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 * 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); 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;
}
} }

View File

@ -5,6 +5,13 @@ namespace Blax\Shop\Services;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem; use Blax\Shop\Models\CartItem;
use Blax\Shop\Contracts\Cartable; 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\Database\Eloquent\Model;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
@ -80,6 +87,8 @@ class CartService
* @param int $quantity * @param int $quantity
* @param array $parameters * @param array $parameters
* @return CartItem * @return CartItem
* @throws HasNoPriceException
* @throws HasNoDefaultPriceException
*/ */
public function add(Model $product, int $quantity = 1, array $parameters = []): CartItem 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.'); 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); return $user->addToCart($product, $quantity, $parameters);
} }
@ -310,4 +324,195 @@ class CartService
return $cart->getPaidAmount(); 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;
}
} }

View File

@ -65,14 +65,20 @@ trait HasCart
if ($product_or_price instanceof Product) { if ($product_or_price instanceof Product) {
$product_or_price->claimStock($quantity); $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) { if (!$isPoolWithInheritedPricing) {
throw new NotPurchasable("Product has no default price"); $default_prices = $product_or_price->defaultPrice()->count();
}
if ($default_prices > 1) { if ($default_prices === 0) {
throw new MultiplePurchaseOptions("Product has multiple default prices, please specify a price to add to cart"); 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");
}
} }
} }

View File

@ -0,0 +1,450 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Exceptions\NotPurchasable;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Services\CartService;
use Workbench\App\Models\User;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
class BookingTimespanValidationTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Product $bookingProduct;
private CartService $cartService;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@ -0,0 +1,421 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Facades\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
class CartServiceBookingTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Product $bookingProduct;
protected Product $poolProduct;
protected Product $singleItem1;
protected Product $singleItem2;
protected ProductPrice $bookingPrice;
protected ProductPrice $poolPrice;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@ -0,0 +1,430 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
class PoolProductCheckoutTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Product $hotelRoom;
protected Product $parkingPool;
protected Product $parkingSpot1;
protected Product $parkingSpot2;
protected Product $parkingSpot3;
protected function setUp(): void
{
parent::setUp();
$this->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()
);
}
}

View File

@ -0,0 +1,427 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
class PoolProductPricingTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Product $poolProduct;
protected Product $singleItem1;
protected Product $singleItem2;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@ -0,0 +1,611 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
class PoolProductTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Product $hotelRoom;
protected Product $parkingPool;
protected Product $parkingSpot1;
protected Product $parkingSpot2;
protected Product $parkingSpot3;
protected ProductPrice $hotelPrice;
protected ProductPrice $parkingPrice;
protected function setUp(): void
{
parent::setUp();
$this->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));
}
}

View File

@ -0,0 +1,317 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Exceptions\HasNoDefaultPriceException;
use Blax\Shop\Exceptions\HasNoPriceException;
use Blax\Shop\Facades\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
class ProductPricingValidationTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}