A booking/pool procedures & tests, IA tests, exceptions
This commit is contained in:
parent
cabae43950
commit
67917e6a31
|
|
@ -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);
|
||||
```
|
||||
|
|
@ -282,6 +282,8 @@ return new class extends Migration
|
|||
$table->decimal('subtotal', 10, 2);
|
||||
$table->json('parameters')->nullable();
|
||||
$table->json('meta')->nullable();
|
||||
$table->timestamp('from')->nullable();
|
||||
$table->timestamp('until')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['cart_id', 'purchasable_id']);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ enum ProductType: string
|
|||
case EXTERNAL = 'external';
|
||||
case BOOKING = 'booking';
|
||||
case VARIATION = 'variation';
|
||||
case POOL = 'pool';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
|
|
@ -20,6 +21,7 @@ enum ProductType: string
|
|||
self::EXTERNAL => 'External',
|
||||
self::BOOKING => 'Booking',
|
||||
self::VARIATION => 'Variation',
|
||||
self::POOL => 'Pool',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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" .
|
||||
"}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -248,21 +248,56 @@ class Cart extends Model
|
|||
$product = $item->purchasable;
|
||||
$quantity = $item->quantity;
|
||||
|
||||
// Extract booking dates from parameters if this is a booking product
|
||||
$from = null;
|
||||
$until = null;
|
||||
if ($product->type === ProductType::BOOKING && $item->parameters) {
|
||||
$params = is_array($item->parameters) ? $item->parameters : (array) $item->parameters;
|
||||
$from = $params['from'] ?? null;
|
||||
$until = $params['until'] ?? null;
|
||||
// Get booking dates from cart item directly (preferred) or from parameters (legacy)
|
||||
$from = $item->from;
|
||||
$until = $item->until;
|
||||
|
||||
// Convert to Carbon instances if they're strings
|
||||
if ($from && is_string($from)) {
|
||||
$from = \Carbon\Carbon::parse($from);
|
||||
if (!$from || !$until) {
|
||||
if (($product->type === ProductType::BOOKING || $product->type === ProductType::POOL) && $item->parameters) {
|
||||
$params = is_array($item->parameters) ? $item->parameters : (array) $item->parameters;
|
||||
$from = $params['from'] ?? null;
|
||||
$until = $params['until'] ?? null;
|
||||
|
||||
// Convert to Carbon instances if they're strings
|
||||
if ($from && is_string($from)) {
|
||||
$from = \Carbon\Carbon::parse($from);
|
||||
}
|
||||
if ($until && is_string($until)) {
|
||||
$until = \Carbon\Carbon::parse($until);
|
||||
}
|
||||
}
|
||||
if ($until && is_string($until)) {
|
||||
$until = \Carbon\Carbon::parse($until);
|
||||
}
|
||||
|
||||
// Handle pool products with booking single items
|
||||
if ($product instanceof Product && $product->isPool()) {
|
||||
// Check if pool with booking items requires timespan
|
||||
if ($product->hasBookingSingleItems() && (!$from || !$until)) {
|
||||
throw new \Exception("Pool product '{$product->name}' with booking items requires a timespan (from/until dates).");
|
||||
}
|
||||
|
||||
// If pool has timespan and has booking single items, claim stock from single items
|
||||
if ($from && $until && $product->hasBookingSingleItems()) {
|
||||
try {
|
||||
$claimedItems = $product->claimPoolStock(
|
||||
$quantity,
|
||||
$this,
|
||||
$from,
|
||||
$until,
|
||||
"Checkout from cart {$this->id}"
|
||||
);
|
||||
|
||||
// Store claimed items info in purchase meta
|
||||
$item->updateMetaKey('claimed_single_items', array_map(fn($i) => $i->id, $claimedItems));
|
||||
$item->save();
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception("Failed to checkout pool product '{$product->name}': " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate booking products have required dates
|
||||
if ($product instanceof Product && $product->isBooking() && (!$from || !$until)) {
|
||||
throw new \Exception("Booking product '{$product->name}' requires a timespan (from/until dates).");
|
||||
}
|
||||
|
||||
$purchase = $this->customer->purchase(
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ class CartItem extends Model
|
|||
'parameters',
|
||||
'purchase_id',
|
||||
'meta',
|
||||
'from',
|
||||
'until',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
@ -31,6 +33,8 @@ class CartItem extends Model
|
|||
'subtotal' => 'decimal:2',
|
||||
'parameters' => 'array',
|
||||
'meta' => 'array',
|
||||
'from' => 'datetime',
|
||||
'until' => 'datetime',
|
||||
];
|
||||
|
||||
public function __construct(array $attributes = [])
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ use Blax\Shop\Enums\ProductStatus;
|
|||
use Blax\Shop\Enums\ProductType;
|
||||
use Blax\Shop\Enums\StockStatus;
|
||||
use Blax\Shop\Enums\StockType;
|
||||
use Blax\Shop\Exceptions\HasNoDefaultPriceException;
|
||||
use Blax\Shop\Exceptions\HasNoPriceException;
|
||||
use Blax\Shop\Exceptions\InvalidBookingConfigurationException;
|
||||
use Blax\Shop\Exceptions\InvalidPoolConfigurationException;
|
||||
use Blax\Shop\Traits\HasCategories;
|
||||
use Blax\Shop\Traits\HasPrices;
|
||||
use Blax\Shop\Traits\HasProductRelations;
|
||||
|
|
@ -312,6 +316,148 @@ class Product extends Model implements Purchasable, Cartable
|
|||
return $this->type === ProductType::BOOKING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a pool product
|
||||
*/
|
||||
public function isPool(): bool
|
||||
{
|
||||
return $this->type === ProductType::POOL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum available quantity for a pool product based on single items
|
||||
*/
|
||||
public function getPoolMaxQuantity(\DateTimeInterface $from = null, \DateTimeInterface $until = null): int
|
||||
{
|
||||
if (!$this->isPool()) {
|
||||
return $this->getAvailableStock();
|
||||
}
|
||||
|
||||
$singleItems = $this->singleProducts;
|
||||
|
||||
if ($singleItems->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If no dates provided, return the count of single items
|
||||
if (!$from || !$until) {
|
||||
return $singleItems->count();
|
||||
}
|
||||
|
||||
// Check availability for each single item during the timespan
|
||||
$availableCount = 0;
|
||||
foreach ($singleItems as $item) {
|
||||
if ($item->isAvailableForBooking($from, $until, 1)) {
|
||||
$availableCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $availableCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim stock for a pool product
|
||||
* This will claim stock from the available single items
|
||||
*
|
||||
* @param int $quantity Number of pool items to claim
|
||||
* @param mixed $reference Reference model
|
||||
* @param \DateTimeInterface|null $from Start date
|
||||
* @param \DateTimeInterface|null $until End date
|
||||
* @param string|null $note Optional note
|
||||
* @return array Array of claimed single item products
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function claimPoolStock(
|
||||
int $quantity,
|
||||
$reference = null,
|
||||
?\DateTimeInterface $from = null,
|
||||
?\DateTimeInterface $until = null,
|
||||
?string $note = null
|
||||
): array {
|
||||
if (!$this->isPool()) {
|
||||
throw new \Exception('This method is only for pool products');
|
||||
}
|
||||
|
||||
$singleItems = $this->singleProducts;
|
||||
|
||||
if ($singleItems->isEmpty()) {
|
||||
throw new \Exception('Pool product has no single items to claim');
|
||||
}
|
||||
|
||||
// Get available single items for the period
|
||||
$availableItems = [];
|
||||
foreach ($singleItems as $item) {
|
||||
if ($item->isAvailableForBooking($from, $until, 1)) {
|
||||
$availableItems[] = $item;
|
||||
}
|
||||
|
||||
if (count($availableItems) >= $quantity) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($availableItems) < $quantity) {
|
||||
throw new \Exception("Only " . count($availableItems) . " items available, but {$quantity} requested");
|
||||
}
|
||||
|
||||
// Claim stock from each selected single item
|
||||
$claimedItems = [];
|
||||
foreach (array_slice($availableItems, 0, $quantity) as $item) {
|
||||
$item->claimStock(1, $reference, $from, $until, $note);
|
||||
$claimedItems[] = $item;
|
||||
}
|
||||
|
||||
return $claimedItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release pool stock claims
|
||||
*
|
||||
* @param mixed $reference Reference model used when claiming
|
||||
* @return int Number of claims released
|
||||
*/
|
||||
public function releasePoolStock($reference): int
|
||||
{
|
||||
if (!$this->isPool()) {
|
||||
throw new \Exception('This method is only for pool products');
|
||||
}
|
||||
|
||||
$singleItems = $this->singleProducts;
|
||||
$released = 0;
|
||||
|
||||
foreach ($singleItems as $item) {
|
||||
$referenceType = is_object($reference) ? get_class($reference) : null;
|
||||
$referenceId = is_object($reference) ? $reference->id : null;
|
||||
|
||||
// Find and delete claims for this reference
|
||||
$claims = $item->stocks()
|
||||
->where('type', StockType::CLAIMED->value)
|
||||
->where('status', StockStatus::PENDING->value)
|
||||
->where('reference_type', $referenceType)
|
||||
->where('reference_id', $referenceId)
|
||||
->get();
|
||||
|
||||
foreach ($claims as $claim) {
|
||||
$claim->release();
|
||||
$released++;
|
||||
}
|
||||
}
|
||||
|
||||
return $released;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any single item in pool is a booking product
|
||||
*/
|
||||
public function hasBookingSingleItems(): bool
|
||||
{
|
||||
if (!$this->isPool()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->singleProducts()->where('products.type', ProductType::BOOKING->value)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check stock availability for a booking period
|
||||
*/
|
||||
|
|
@ -359,4 +505,572 @@ class Product extends Model implements Purchasable, Cartable
|
|||
{
|
||||
return $query->where('type', ProductType::BOOKING->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current price with pool product inheritance support
|
||||
*/
|
||||
public function getCurrentPrice(bool|null $sales_price = null): ?float
|
||||
{
|
||||
// If this is a pool product and it has no direct price, inherit from single items
|
||||
if ($this->isPool() && !$this->hasPrice()) {
|
||||
return $this->getInheritedPoolPrice();
|
||||
}
|
||||
|
||||
// If pool has a direct price, use it
|
||||
if ($this->isPool() && $this->hasPrice()) {
|
||||
return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
|
||||
}
|
||||
|
||||
// For non-pool products, use the trait's default behavior
|
||||
return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inherited price from single items based on pricing strategy
|
||||
*/
|
||||
protected function getInheritedPoolPrice(): ?float
|
||||
{
|
||||
if (!$this->isPool()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$strategy = $this->getPoolPricingStrategy();
|
||||
|
||||
$singleItems = $this->singleProducts;
|
||||
|
||||
if ($singleItems->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prices = $singleItems->map(function ($item) {
|
||||
return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
|
||||
})->filter()->values();
|
||||
|
||||
if ($prices->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($strategy) {
|
||||
'lowest' => $prices->min(),
|
||||
'highest' => $prices->max(),
|
||||
'average' => round($prices->avg()),
|
||||
default => round($prices->avg()), // Default to average
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pool pricing strategy from metadata
|
||||
*/
|
||||
public function getPoolPricingStrategy(): string
|
||||
{
|
||||
if (!$this->isPool()) {
|
||||
return 'average';
|
||||
}
|
||||
|
||||
$meta = $this->getMeta();
|
||||
return $meta->pricing_strategy ?? 'average';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the pool pricing strategy
|
||||
*/
|
||||
public function setPoolPricingStrategy(string $strategy): void
|
||||
{
|
||||
if (!$this->isPool()) {
|
||||
throw new \Exception('This method is only for pool products');
|
||||
}
|
||||
|
||||
if (!in_array($strategy, ['average', 'lowest', 'highest'])) {
|
||||
throw new \InvalidArgumentException("Invalid pricing strategy: {$strategy}");
|
||||
}
|
||||
|
||||
$this->updateMetaKey('pricing_strategy', $strategy);
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lowest price from single items
|
||||
*/
|
||||
public function getLowestPoolPrice(): ?float
|
||||
{
|
||||
if (!$this->isPool()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$singleItems = $this->singleProducts;
|
||||
|
||||
if ($singleItems->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prices = $singleItems->map(function ($item) {
|
||||
return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
|
||||
})->filter()->values();
|
||||
|
||||
return $prices->isEmpty() ? null : $prices->min();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the highest price from single items
|
||||
*/
|
||||
public function getHighestPoolPrice(): ?float
|
||||
{
|
||||
if (!$this->isPool()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$singleItems = $this->singleProducts;
|
||||
|
||||
if ($singleItems->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prices = $singleItems->map(function ($item) {
|
||||
return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
|
||||
})->filter()->values();
|
||||
|
||||
return $prices->isEmpty() ? null : $prices->max();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the price range for pool products
|
||||
*/
|
||||
public function getPoolPriceRange(): ?array
|
||||
{
|
||||
if (!$this->isPool()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lowest = $this->getLowestPoolPrice();
|
||||
$highest = $this->getHighestPoolPrice();
|
||||
|
||||
if ($lowest === null || $highest === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'min' => $lowest,
|
||||
'max' => $highest,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate pool product configuration and provide helpful error messages
|
||||
*
|
||||
* @throws InvalidPoolConfigurationException
|
||||
*/
|
||||
public function validatePoolConfiguration(bool $throwOnWarnings = false): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
|
||||
if (!$this->isPool()) {
|
||||
throw InvalidPoolConfigurationException::notAPoolProduct($this->name);
|
||||
}
|
||||
|
||||
$singleItems = $this->singleProducts;
|
||||
|
||||
// Critical: No single items
|
||||
if ($singleItems->isEmpty()) {
|
||||
throw InvalidPoolConfigurationException::noSingleItems($this->name);
|
||||
}
|
||||
|
||||
// Check for mixed product types
|
||||
$types = $singleItems->pluck('type')->unique();
|
||||
if ($types->count() > 1) {
|
||||
$warning = "Mixed single item types detected. This may cause unexpected behavior.";
|
||||
$warnings[] = $warning;
|
||||
if ($throwOnWarnings) {
|
||||
throw InvalidPoolConfigurationException::mixedSingleItemTypes($this->name);
|
||||
}
|
||||
}
|
||||
|
||||
// Check stock management on single items
|
||||
$itemsWithoutStock = $singleItems->filter(fn($item) => !$item->manage_stock);
|
||||
if ($itemsWithoutStock->isNotEmpty()) {
|
||||
$itemNames = $itemsWithoutStock->pluck('name')->toArray();
|
||||
$errors[] = "Single items without stock management: " . implode(', ', $itemNames);
|
||||
throw InvalidPoolConfigurationException::singleItemsWithoutStock($this->name, $itemNames);
|
||||
}
|
||||
|
||||
// Check for items with zero stock
|
||||
$itemsWithZeroStock = $singleItems->filter(fn($item) => $item->getAvailableStock() <= 0);
|
||||
if ($itemsWithZeroStock->isNotEmpty()) {
|
||||
$itemNames = $itemsWithZeroStock->pluck('name')->toArray();
|
||||
$warnings[] = "Single items with zero stock: " . implode(', ', $itemNames);
|
||||
if ($throwOnWarnings) {
|
||||
throw InvalidPoolConfigurationException::singleItemsWithZeroStock($this->name, $itemNames);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate booking product configuration and provide helpful error messages
|
||||
*
|
||||
* @throws InvalidBookingConfigurationException
|
||||
*/
|
||||
public function validateBookingConfiguration(bool $throwOnWarnings = false): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
|
||||
if (!$this->isBooking()) {
|
||||
throw InvalidBookingConfigurationException::notABookingProduct($this->name);
|
||||
}
|
||||
|
||||
// Critical: Stock management must be enabled
|
||||
if (!$this->manage_stock) {
|
||||
throw InvalidBookingConfigurationException::stockManagementNotEnabled($this->name);
|
||||
}
|
||||
|
||||
// Check for available stock
|
||||
if ($this->getAvailableStock() <= 0) {
|
||||
$warnings[] = "No stock available for booking";
|
||||
if ($throwOnWarnings) {
|
||||
throw InvalidBookingConfigurationException::noStockAvailable($this->name);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for pricing
|
||||
if (!$this->hasPrice()) {
|
||||
$warnings[] = "No pricing configured";
|
||||
if ($throwOnWarnings) {
|
||||
throw InvalidBookingConfigurationException::noPricingConfigured($this->name);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate product pricing configuration
|
||||
*
|
||||
* @throws HasNoPriceException
|
||||
* @throws HasNoDefaultPriceException
|
||||
*/
|
||||
public function validatePricing(bool $throwExceptions = true): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
|
||||
// Special handling for pool products
|
||||
if ($this->isPool()) {
|
||||
$hasDirectPrice = $this->prices()->exists();
|
||||
$singleItems = $this->singleProducts;
|
||||
|
||||
if (!$hasDirectPrice) {
|
||||
// Check if single items have prices to inherit
|
||||
$singleItemsWithPrices = $singleItems->filter(function ($item) {
|
||||
return $item->prices()->exists();
|
||||
});
|
||||
|
||||
if ($singleItemsWithPrices->isEmpty()) {
|
||||
$errors[] = "Pool product has no pricing (direct or inherited)";
|
||||
if ($throwExceptions) {
|
||||
throw HasNoPriceException::poolProductNoPriceAndNoSingleItemPrices($this->name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If pool has direct prices, validate them
|
||||
if ($hasDirectPrice) {
|
||||
return $this->validateDirectPricing($throwExceptions);
|
||||
}
|
||||
|
||||
// Pool with inherited pricing is valid
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
// For all other product types, validate direct pricing
|
||||
return $this->validateDirectPricing($throwExceptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate direct pricing on the product
|
||||
*
|
||||
* @throws HasNoPriceException
|
||||
* @throws HasNoDefaultPriceException
|
||||
*/
|
||||
protected function validateDirectPricing(bool $throwExceptions = true): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
|
||||
$allPrices = $this->prices;
|
||||
$priceCount = $allPrices->count();
|
||||
|
||||
// No prices at all
|
||||
if ($priceCount === 0) {
|
||||
$errors[] = "Product has no prices configured";
|
||||
if ($throwExceptions) {
|
||||
throw HasNoPriceException::noPricesConfigured($this->name, $this->id);
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => false,
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
$defaultPrices = $allPrices->where('is_default', true);
|
||||
$defaultCount = $defaultPrices->count();
|
||||
|
||||
// Multiple default prices
|
||||
if ($defaultCount > 1) {
|
||||
$errors[] = "Product has {$defaultCount} default prices (should have exactly 1)";
|
||||
if ($throwExceptions) {
|
||||
throw HasNoDefaultPriceException::multipleDefaultPrices($this->name, $defaultCount);
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => false,
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
// No default price
|
||||
if ($defaultCount === 0) {
|
||||
if ($priceCount === 1) {
|
||||
// Single price but not marked as default
|
||||
$errors[] = "Product has one price but it's not marked as default";
|
||||
if ($throwExceptions) {
|
||||
throw HasNoDefaultPriceException::onlyNonDefaultPriceExists($this->name);
|
||||
}
|
||||
} else {
|
||||
// Multiple prices but none are default
|
||||
$errors[] = "Product has {$priceCount} prices but none are marked as default";
|
||||
if ($throwExceptions) {
|
||||
throw HasNoDefaultPriceException::multiplePricesNoDefault($this->name, $priceCount);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => false,
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
// Valid: Exactly one default price
|
||||
return [
|
||||
'valid' => true,
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get helpful setup instructions for pool products
|
||||
*/
|
||||
public static function getPoolSetupInstructions(): string
|
||||
{
|
||||
return <<<'INSTRUCTIONS'
|
||||
# Pool Product Setup Guide
|
||||
|
||||
Pool products aggregate multiple individual items (e.g., parking spots, hotel rooms)
|
||||
into a single purchasable product where customers don't need to select specific items.
|
||||
|
||||
## Step 1: Create the Pool Product
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
|
||||
$pool = Product::create([
|
||||
'type' => ProductType::POOL,
|
||||
'name' => 'Parking Lot',
|
||||
'slug' => 'parking-lot',
|
||||
]);
|
||||
```
|
||||
|
||||
## Step 2: Create Single Items (Booking Products)
|
||||
|
||||
```php
|
||||
$spot1 = Product::create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'name' => 'Parking Spot #1',
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
$spot1->increaseStock(1);
|
||||
|
||||
$spot2 = Product::create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'name' => 'Parking Spot #2',
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
$spot2->increaseStock(1);
|
||||
```
|
||||
|
||||
## Step 3: Link Single Items to Pool
|
||||
|
||||
```php
|
||||
use Blax\Shop\Enums\ProductRelationType;
|
||||
|
||||
$pool->productRelations()->attach([
|
||||
$spot1->id => ['type' => ProductRelationType::SINGLE],
|
||||
$spot2->id => ['type' => ProductRelationType::SINGLE],
|
||||
]);
|
||||
```
|
||||
|
||||
## Step 4: Set Pricing (Optional)
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\ProductPrice;
|
||||
|
||||
// Option A: Set price on pool (takes precedence)
|
||||
ProductPrice::create([
|
||||
'purchasable_id' => $pool->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'unit_amount' => 5000, // 50.00 per day
|
||||
'currency' => 'USD',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
// Option B: Set prices on single items (pool inherits)
|
||||
ProductPrice::create([
|
||||
'purchasable_id' => $spot1->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'unit_amount' => 5000,
|
||||
'currency' => 'USD',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
// Set pricing strategy (if using inheritance)
|
||||
$pool->setPoolPricingStrategy('average'); // or 'lowest' or 'highest'
|
||||
```
|
||||
|
||||
## Step 5: Add to Cart with Timespan
|
||||
|
||||
```php
|
||||
use Blax\Shop\Facades\Cart;
|
||||
use Carbon\Carbon;
|
||||
|
||||
Cart::addBooking(
|
||||
$pool,
|
||||
2, // quantity
|
||||
Carbon::parse('2025-01-15 09:00'), // from
|
||||
Carbon::parse('2025-01-17 17:00'), // until
|
||||
);
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
```php
|
||||
// Validate configuration before use
|
||||
$validation = $pool->validatePoolConfiguration();
|
||||
if (!$validation['valid']) {
|
||||
foreach ($validation['errors'] as $error) {
|
||||
echo "Error: $error\n";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
INSTRUCTIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get helpful setup instructions for booking products
|
||||
*/
|
||||
public static function getBookingSetupInstructions(): string
|
||||
{
|
||||
return <<<'INSTRUCTIONS'
|
||||
# Booking Product Setup Guide
|
||||
|
||||
Booking products represent time-based reservations (conference rooms, equipment, etc.)
|
||||
|
||||
## Step 1: Create the Booking Product
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
|
||||
$product = Product::create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'name' => 'Conference Room A',
|
||||
'manage_stock' => true, // REQUIRED for bookings
|
||||
]);
|
||||
```
|
||||
|
||||
## Step 2: Set Initial Stock
|
||||
|
||||
```php
|
||||
// For single-unit bookings (1 room, 1 equipment piece, etc.)
|
||||
$product->increaseStock(1);
|
||||
|
||||
// For multiple units (e.g., 5 identical meeting rooms)
|
||||
$product->increaseStock(5);
|
||||
```
|
||||
|
||||
## Step 3: Configure Pricing
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\ProductPrice;
|
||||
|
||||
ProductPrice::create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'unit_amount' => 10000, // Price per day in cents (100.00 USD)
|
||||
'currency' => 'USD',
|
||||
'is_default' => true,
|
||||
]);
|
||||
```
|
||||
|
||||
## Step 4: Add to Cart with Timespan
|
||||
|
||||
```php
|
||||
use Blax\Shop\Facades\Cart;
|
||||
use Carbon\Carbon;
|
||||
|
||||
Cart::addBooking(
|
||||
$product,
|
||||
1, // quantity
|
||||
Carbon::parse('2025-01-15 09:00'), // from
|
||||
Carbon::parse('2025-01-17 17:00'), // until
|
||||
);
|
||||
|
||||
// Price will be: 100.00/day × 3 days = 300.00
|
||||
```
|
||||
|
||||
## Check Availability
|
||||
|
||||
```php
|
||||
$from = Carbon::parse('2025-01-15 09:00');
|
||||
$until = Carbon::parse('2025-01-17 17:00');
|
||||
|
||||
if ($product->isAvailableForBooking($from, $until, 1)) {
|
||||
// Product is available for this period
|
||||
Cart::addBooking($product, 1, $from, $until);
|
||||
}
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
```php
|
||||
// Validate configuration
|
||||
$validation = $product->validateBookingConfiguration();
|
||||
if (!$validation['valid']) {
|
||||
foreach ($validation['errors'] as $error) {
|
||||
echo "Error: $error\n";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
INSTRUCTIONS;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ namespace Blax\Shop\Services;
|
|||
use Blax\Shop\Models\Cart;
|
||||
use Blax\Shop\Models\CartItem;
|
||||
use Blax\Shop\Contracts\Cartable;
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
use Blax\Shop\Exceptions\HasNoDefaultPriceException;
|
||||
use Blax\Shop\Exceptions\HasNoPriceException;
|
||||
use Blax\Shop\Exceptions\InvalidBookingConfigurationException;
|
||||
use Blax\Shop\Exceptions\InvalidPoolConfigurationException;
|
||||
use Blax\Shop\Exceptions\NotPurchasable;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
|
|
@ -80,6 +87,8 @@ class CartService
|
|||
* @param int $quantity
|
||||
* @param array $parameters
|
||||
* @return CartItem
|
||||
* @throws HasNoPriceException
|
||||
* @throws HasNoDefaultPriceException
|
||||
*/
|
||||
public function add(Model $product, int $quantity = 1, array $parameters = []): CartItem
|
||||
{
|
||||
|
|
@ -89,6 +98,11 @@ class CartService
|
|||
throw new \Exception('No authenticated user found. Use guest() for guest carts.');
|
||||
}
|
||||
|
||||
// Validate pricing before adding to cart
|
||||
if ($product instanceof Product) {
|
||||
$product->validatePricing(throwExceptions: true);
|
||||
}
|
||||
|
||||
return $user->addToCart($product, $quantity, $parameters);
|
||||
}
|
||||
|
||||
|
|
@ -310,4 +324,195 @@ class CartService
|
|||
|
||||
return $cart->getPaidAmount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cart items for booking products
|
||||
* Checks if all booking products have valid timespans and stock availability
|
||||
*
|
||||
* @param Cart|null $cart
|
||||
* @return array Array of validation errors
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function validateBookings(?Cart $cart = null): array
|
||||
{
|
||||
if (!$cart) {
|
||||
$cart = $this->current();
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
|
||||
foreach ($cart->items as $item) {
|
||||
$product = $item->purchasable;
|
||||
|
||||
if (!$product instanceof Product) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if booking product has timespan
|
||||
if ($product->isBooking() && (!$item->from || !$item->until)) {
|
||||
$errors[] = "Booking product '{$product->name}' requires a timespan (from/until dates).";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if pool product with booking items has timespan
|
||||
if ($product->isPool() && $product->hasBookingSingleItems()) {
|
||||
// If pool has a timespan, validate it
|
||||
if ($item->from && $item->until) {
|
||||
// Check if quantity is available for the timespan
|
||||
$maxQuantity = $product->getPoolMaxQuantity($item->from, $item->until);
|
||||
if ($item->quantity > $maxQuantity) {
|
||||
$errors[] = "Only {$maxQuantity} '{$product->name}' available for the selected period. You requested {$item->quantity}.";
|
||||
}
|
||||
} else {
|
||||
// Check if individual single items have timespans in meta
|
||||
$meta = $item->getMeta();
|
||||
$hasIndividualTimespans = $meta->individual_timespans ?? false;
|
||||
|
||||
if (!$hasIndividualTimespans) {
|
||||
$errors[] = "Pool product '{$product->name}' with booking items requires either a timespan or individual timespans for each item.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate stock availability for booking period
|
||||
if ($product->isBooking() && $item->from && $item->until) {
|
||||
if (!$product->isAvailableForBooking($item->from, $item->until, $item->quantity)) {
|
||||
$errors[] = "'{$product->name}' is not available for the selected period (insufficient stock).";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cart has valid bookings
|
||||
*
|
||||
* @param Cart|null $cart
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function hasValidBookings(?Cart $cart = null): bool
|
||||
{
|
||||
return empty($this->validateBookings($cart));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a booking product to cart with timespan
|
||||
*
|
||||
* @param Model&Cartable $product
|
||||
* @param int $quantity
|
||||
* @param \DateTimeInterface $from
|
||||
* @param \DateTimeInterface $until
|
||||
* @param array $parameters
|
||||
* @return CartItem
|
||||
* @throws HasNoPriceException
|
||||
* @throws HasNoDefaultPriceException
|
||||
* @throws InvalidBookingConfigurationException
|
||||
* @throws InvalidPoolConfigurationException
|
||||
* @throws NotPurchasable
|
||||
*/
|
||||
public function addBooking(
|
||||
Model $product,
|
||||
int $quantity,
|
||||
\DateTimeInterface $from,
|
||||
\DateTimeInterface $until,
|
||||
array $parameters = []
|
||||
): CartItem {
|
||||
$user = auth()->user();
|
||||
|
||||
if (!$user) {
|
||||
throw new \Exception('No authenticated user found. Use guest() for guest carts.');
|
||||
}
|
||||
|
||||
// Validate timespan
|
||||
if ($from >= $until) {
|
||||
throw InvalidBookingConfigurationException::invalidTimespan($from, $until);
|
||||
}
|
||||
|
||||
if ($from->lessThan(now())) {
|
||||
throw InvalidBookingConfigurationException::invalidTimespan($from, $until);
|
||||
}
|
||||
|
||||
// Validate the product type and configuration
|
||||
if ($product instanceof Product) {
|
||||
if (!$product->isBooking() && !$product->isPool()) {
|
||||
throw new \Exception(
|
||||
"Product '{$product->name}' is not a booking or pool type.\n\n" .
|
||||
"For booking products:\n" .
|
||||
Product::getBookingSetupInstructions() . "\n\n" .
|
||||
"For pool products:\n" .
|
||||
Product::getPoolSetupInstructions()
|
||||
);
|
||||
}
|
||||
|
||||
// Validate pricing before adding to cart
|
||||
$product->validatePricing(throwExceptions: true);
|
||||
|
||||
// Validate booking product configuration
|
||||
if ($product->isBooking()) {
|
||||
$product->validateBookingConfiguration();
|
||||
}
|
||||
|
||||
// Validate pool product configuration
|
||||
if ($product->isPool()) {
|
||||
$product->validatePoolConfiguration();
|
||||
}
|
||||
} // Check availability
|
||||
if ($product instanceof Product && $product->isBooking()) {
|
||||
if (!$product->isAvailableForBooking($from, $until, $quantity)) {
|
||||
$available = $product->getAvailableStock();
|
||||
throw InvalidBookingConfigurationException::notAvailableForPeriod(
|
||||
$product->name,
|
||||
$from,
|
||||
$until,
|
||||
$quantity,
|
||||
$available
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check pool product availability
|
||||
if ($product instanceof Product && $product->isPool()) {
|
||||
$maxQuantity = $product->getPoolMaxQuantity($from, $until);
|
||||
if ($quantity > $maxQuantity) {
|
||||
throw InvalidPoolConfigurationException::notEnoughAvailableItems(
|
||||
$product->name,
|
||||
$from,
|
||||
$until,
|
||||
$quantity,
|
||||
$maxQuantity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to cart with timespan
|
||||
$cart = $user->currentCart();
|
||||
|
||||
$pricePerDay = $product->getCurrentPrice();
|
||||
|
||||
// Calculate price based on days for booking products
|
||||
if ($product instanceof Product && ($product->isBooking() || $product->isPool())) {
|
||||
$days = $from->diff($until)->days;
|
||||
$pricePerUnit = $pricePerDay * $days; // Price for one unit for the entire period
|
||||
$totalPrice = $pricePerUnit * $quantity; // Total for all units
|
||||
} else {
|
||||
$pricePerUnit = $pricePerDay;
|
||||
$totalPrice = $pricePerDay * $quantity;
|
||||
}
|
||||
|
||||
$cartItem = $cart->items()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => get_class($product),
|
||||
'quantity' => $quantity,
|
||||
'price' => $pricePerUnit, // Price per unit for the period
|
||||
'subtotal' => $totalPrice, // Total for all units
|
||||
'regular_price' => $pricePerDay,
|
||||
'parameters' => $parameters,
|
||||
'from' => $from,
|
||||
'until' => $until,
|
||||
]);
|
||||
|
||||
return $cartItem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,14 +65,20 @@ trait HasCart
|
|||
if ($product_or_price instanceof Product) {
|
||||
$product_or_price->claimStock($quantity);
|
||||
|
||||
$default_prices = $product_or_price->defaultPrice()->count();
|
||||
// Skip default price validation for pool products without direct prices
|
||||
// (they inherit pricing from single items and are validated in validatePricing())
|
||||
$isPoolWithInheritedPricing = $product_or_price->isPool() && !$product_or_price->prices()->exists();
|
||||
|
||||
if ($default_prices === 0) {
|
||||
throw new NotPurchasable("Product has no default price");
|
||||
}
|
||||
if (!$isPoolWithInheritedPricing) {
|
||||
$default_prices = $product_or_price->defaultPrice()->count();
|
||||
|
||||
if ($default_prices > 1) {
|
||||
throw new MultiplePurchaseOptions("Product has multiple default prices, please specify a price to add to cart");
|
||||
if ($default_prices === 0) {
|
||||
throw new NotPurchasable("Product has no default price");
|
||||
}
|
||||
|
||||
if ($default_prices > 1) {
|
||||
throw new MultiplePurchaseOptions("Product has multiple default prices, please specify a price to add to cart");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue