BFI cart, A stripe logic, checkout & tests, A tests, RA pool product

This commit is contained in:
Fabian @ Blax Software 2025-12-15 14:10:59 +01:00
parent d2cf70ce44
commit 3045f72304
22 changed files with 2955 additions and 345 deletions

View File

@ -61,6 +61,7 @@ return [
'stripe' => [ 'stripe' => [
'enabled' => env('SHOP_STRIPE_ENABLED', false), 'enabled' => env('SHOP_STRIPE_ENABLED', false),
'sync_prices' => true, 'sync_prices' => true,
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
], ],
// Cache configuration // Cache configuration

279
docs/04-stripe-checkout.md Normal file
View File

@ -0,0 +1,279 @@
# Stripe Checkout Integration
This document describes the Stripe Checkout integration for the Laravel Shop package.
## Configuration
### Enable Stripe
Add the following to your `.env` file:
```env
SHOP_STRIPE_ENABLED=true
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
```
### Configure Services
In your `config/services.php`:
```php
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
```
## Price Configuration
All products and product prices must have a `stripe_price_id` before they can be used in Stripe Checkout.
### Setting Stripe Price ID
```php
$product = Product::find($id);
$price = $product->defaultPrice()->first();
$price->update(['stripe_price_id' => 'price_...']);
```
## Creating a Checkout Session
### API Endpoint
```
POST /api/shop/stripe/checkout/{cartId}
```
### Example Request
```bash
curl -X POST https://your-domain.com/api/shop/stripe/checkout/cart-uuid-here \
-H "Authorization: Bearer YOUR_TOKEN"
```
### Example Response
```json
{
"session_id": "cs_test_...",
"url": "https://checkout.stripe.com/c/pay/cs_test_..."
}
```
Redirect the user to the `url` to complete payment.
## Handling Success/Cancel
### Success URL
```
GET /api/shop/stripe/success?session_id={SESSION_ID}&cart_id={CART_ID}
```
When payment is successful:
- Cart status is updated to `CONVERTED`
- Cart's `converted_at` is set
- ProductPurchases are updated with:
- `status``COMPLETED`
- `charge_id` → Stripe Payment Intent ID
- `amount_paid` → Amount from Stripe (in dollars, converted from cents)
### Cancel URL
```
GET /api/shop/stripe/cancel?cart_id={CART_ID}
```
When payment is cancelled, the cart remains in `ACTIVE` status and the user can try again.
## Webhook Handler
### Webhook URL
```
POST /api/shop/stripe/webhook
```
### Supported Events
The webhook handler processes the following Stripe events:
- `checkout.session.completed` - Updates cart to converted, updates purchases
- `checkout.session.async_payment_succeeded` - Same as completed
- `checkout.session.async_payment_failed` - Logs failure
- `charge.succeeded` - Updates purchases with charge info
- `charge.failed` - Marks purchases as `FAILED`
- `payment_intent.succeeded` - Updates purchases
- `payment_intent.payment_failed` - Marks purchases as `FAILED`
### Configuring Webhook in Stripe
1. Go to Stripe Dashboard → Developers → Webhooks
2. Click "Add endpoint"
3. Enter your webhook URL: `https://your-domain.com/api/shop/stripe/webhook`
4. Select events to listen to (or select "receive all events")
5. Copy the signing secret and add it to your `.env` as `STRIPE_WEBHOOK_SECRET`
## Route Customization
### Disabling Stripe Routes
The Stripe routes are automatically registered if:
- `config('shop.stripe.enabled')` is `true`
- Routes haven't already been defined in your Laravel app
You can manually define routes in your application's route files to override the default behavior.
### Custom Routes Example
```php
// routes/web.php or routes/api.php
use Blax\Shop\Http\Controllers\StripeCheckoutController;
use Blax\Shop\Http\Controllers\StripeWebhookController;
Route::post('custom/stripe/checkout/{cartId}', [StripeCheckoutController::class, 'createCheckoutSession'])
->name('shop.stripe.checkout');
Route::get('custom/stripe/success', [StripeCheckoutController::class, 'success'])
->name('shop.stripe.success');
Route::get('custom/stripe/cancel', [StripeCheckoutController::class, 'cancel'])
->name('shop.stripe.cancel');
Route::post('custom/stripe/webhook', [StripeWebhookController::class, 'handleWebhook'])
->name('shop.stripe.webhook');
```
## ProductPurchase Updates
The webhook handler automatically updates ProductPurchase records with charge information if the columns exist:
- `charge_id` - Stripe Payment Intent ID
- `amount_paid` - Amount paid in dollars
These fields are automatically populated from the fillable array on the ProductPurchase model.
## Error Handling
### Missing Stripe Price ID
If a cart item doesn't have a `stripe_price_id`, the checkout session creation will fail with:
```json
{
"error": "Item 'Product Name' is missing a Stripe price ID"
}
```
### Stripe API Errors
All Stripe API errors are caught and logged. The response will include:
```json
{
"error": "Failed to create checkout session: [error message]"
}
```
## Pool Products with MayBePoolProduct Trait
Pool-related methods have been moved to the `MayBePoolProduct` trait to keep the Product model cleaner.
### Using Pool Methods
All pool methods work the same way, they're just now in a trait:
```php
$pool = Product::find($poolId);
// Check if pool
$pool->isPool(); // returns bool
// Get available quantity
$pool->getAvailableQuantity($from, $until);
// Get pool max quantity
$pool->getPoolMaxQuantity($from, $until);
// Claim pool stock
$pool->claimPoolStock($quantity, $reference, $from, $until);
// Release pool stock
$pool->releasePoolStock($reference);
// Pricing methods
$pool->getLowestPoolPrice();
$pool->getHighestPoolPrice();
$pool->getPoolPriceRange();
$pool->setPoolPricingStrategy('lowest'); // 'lowest', 'highest', 'average'
// Validation
$pool->validatePoolConfiguration();
// Availability methods
$pool->getPoolAvailabilityCalendar($start, $end, $quantity);
$pool->getSingleItemsAvailability($from, $until);
$pool->isPoolAvailable($from, $until, $quantity);
$pool->getPoolAvailablePeriods($start, $end, $quantity, $minDays);
```
### Benefits of Trait
- Cleaner Product model
- Pool functionality can be used by other models in the future
- Better separation of concerns
- Easier testing and maintenance
## Example Usage Flow
```php
// 1. Create a product with Stripe price
$product = Product::create([...]);
$price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'stripe_price_id' => 'price_1234567890',
'unit_amount' => 2000, // $20.00 in cents
'is_default' => true,
]);
// 2. Add to cart
$cart = auth()->user()->currentCart();
$cart->addToCart($product, 1);
// 3. Create Stripe checkout session
$response = Http::post('/api/shop/stripe/checkout/' . $cart->id);
$checkoutUrl = $response->json('url');
// 4. Redirect user to Stripe
return redirect($checkoutUrl);
// 5. Stripe redirects back to success URL
// 6. Webhook processes payment
// 7. Cart is converted, purchases are completed
```
## Testing
Mock Stripe in your tests:
```php
use Stripe\Stripe;
use Stripe\Checkout\Session as StripeSession;
// Mock Stripe session creation
Stripe::setApiKey('sk_test_fake');
StripeSession::create([...]); // Use test mode
```
## Security Considerations
1. **Always verify webhook signatures** - Set `STRIPE_WEBHOOK_SECRET` in production
2. **Use HTTPS** - Stripe requires HTTPS for webhooks
3. **Validate cart ownership** - Ensure users can only checkout their own carts
4. **Test mode first** - Use Stripe test keys during development
5. **Monitor webhooks** - Check Stripe Dashboard for webhook delivery issues

View File

@ -2,6 +2,8 @@
use Blax\Shop\Http\Controllers\Api\CategoryController; use Blax\Shop\Http\Controllers\Api\CategoryController;
use Blax\Shop\Http\Controllers\Api\ProductController; use Blax\Shop\Http\Controllers\Api\ProductController;
use Blax\Shop\Http\Controllers\StripeCheckoutController;
use Blax\Shop\Http\Controllers\StripeWebhookController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
$config = config('shop.routes'); $config = config('shop.routes');
@ -31,3 +33,26 @@ Route::prefix($config['prefix'])
Route::get('products/{slug}', [ProductController::class, 'show']) Route::get('products/{slug}', [ProductController::class, 'show'])
->name('products.show'); ->name('products.show');
}); });
// Stripe routes - only if enabled and not already defined by the Laravel instance
if (config('shop.stripe.enabled', false) && !Route::has('shop.stripe.checkout')) {
Route::prefix($config['prefix'])
->middleware($config['middleware'])
->name($config['name_prefix'])
->group(function () {
// Stripe Checkout
Route::post('stripe/checkout/{cartId}', [StripeCheckoutController::class, 'createCheckoutSession'])
->name('stripe.checkout');
Route::get('stripe/success', [StripeCheckoutController::class, 'success'])
->name('stripe.success');
Route::get('stripe/cancel', [StripeCheckoutController::class, 'cancel'])
->name('stripe.cancel');
// Stripe Webhook (no auth middleware)
Route::post('stripe/webhook', [StripeWebhookController::class, 'handleWebhook'])
->withoutMiddleware($config['middleware'])
->name('stripe.webhook');
});
}

View File

@ -12,6 +12,7 @@ enum ProductRelationType: string
case ADD_ON = 'add-on'; case ADD_ON = 'add-on';
case BUNDLE = 'bundle'; case BUNDLE = 'bundle';
case SINGLE = 'single'; case SINGLE = 'single';
case POOL = 'pool';
public function label(): string public function label(): string
@ -25,6 +26,7 @@ enum ProductRelationType: string
self::ADD_ON => 'Add-on', self::ADD_ON => 'Add-on',
self::BUNDLE => 'Bundle', self::BUNDLE => 'Bundle',
self::SINGLE => 'Single', self::SINGLE => 'Single',
self::POOL => 'Pool',
}; };
} }
} }

View File

@ -9,6 +9,7 @@ enum PurchaseStatus: string
case COMPLETED = 'completed'; case COMPLETED = 'completed';
case REFUNDED = 'refunded'; case REFUNDED = 'refunded';
case CART = 'cart'; case CART = 'cart';
case FAILED = 'failed';
public function label(): string public function label(): string
{ {
@ -18,6 +19,7 @@ enum PurchaseStatus: string
self::COMPLETED => 'Completed', self::COMPLETED => 'Completed',
self::REFUNDED => 'Refunded', self::REFUNDED => 'Refunded',
self::CART => 'Cart', self::CART => 'Cart',
self::FAILED => 'Failed',
}; };
} }
} }

View File

@ -0,0 +1,155 @@
<?php
namespace Blax\Shop\Http\Controllers;
use Blax\Shop\Models\Cart;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\Checkout\Session as StripeSession;
class StripeCheckoutController
{
public function __construct()
{
if (config('shop.stripe.enabled')) {
Stripe::setApiKey(config('services.stripe.secret'));
}
}
/**
* Create a Stripe Checkout Session for a cart
*/
public function createCheckoutSession(Request $request, string $cartId)
{
if (!config('shop.stripe.enabled')) {
return response()->json(['error' => 'Stripe is not enabled'], 400);
}
try {
$cart = Cart::findOrFail($cartId);
// Use the cart's checkoutSession method which handles syncing and session creation
$session = $cart->checkoutSession([
'success_url' => $request->input('success_url'),
'cancel_url' => $request->input('cancel_url'),
'metadata' => $request->input('metadata', []),
]);
return response()->json([
'session_id' => $session->id,
'url' => $session->url,
]);
} catch (\Exception $e) {
Log::error('Stripe checkout session creation failed', [
'cart_id' => $cartId,
'error' => $e->getMessage(),
]);
return response()->json([
'error' => 'Failed to create checkout session: ' . $e->getMessage()
], 500);
}
}
/**
* Handle successful payment
*/
public function success(Request $request)
{
$sessionId = $request->get('session_id');
$cartId = $request->get('cart_id');
if (!$sessionId || !$cartId) {
return response()->json(['error' => 'Missing session or cart ID'], 400);
}
try {
$cart = Cart::findOrFail($cartId);
// Verify the session
$session = StripeSession::retrieve($sessionId);
if ($session->payment_status === 'paid') {
// Update cart status to converted
$cart->update([
'status' => \Blax\Shop\Enums\CartStatus::CONVERTED,
'converted_at' => now(),
]);
// Update product purchases with charge information
$purchases = $cart->items()->with('purchase')->get()->pluck('purchase')->filter();
foreach ($purchases as $purchase) {
if ($purchase && method_exists($purchase, 'update')) {
$updateData = [
'status' => \Blax\Shop\Enums\PurchaseStatus::COMPLETED,
];
// Only update if column exists
if (in_array('charge_id', $purchase->getFillable())) {
$updateData['charge_id'] = $session->payment_intent;
}
if (in_array('amount_paid', $purchase->getFillable())) {
$updateData['amount_paid'] = $session->amount_total / 100; // Convert from cents
}
$purchase->update($updateData);
}
}
return response()->json([
'success' => true,
'message' => 'Payment successful',
'cart_id' => $cart->id,
]);
}
return response()->json([
'success' => false,
'message' => 'Payment not completed',
], 400);
} catch (\Exception $e) {
Log::error('Stripe success handler failed', [
'session_id' => $sessionId,
'cart_id' => $cartId,
'error' => $e->getMessage(),
]);
return response()->json([
'error' => 'Failed to process success: ' . $e->getMessage()
], 500);
}
}
/**
* Handle cancelled payment
*/
public function cancel(Request $request)
{
$cartId = $request->get('cart_id');
if (!$cartId) {
return response()->json(['error' => 'Missing cart ID'], 400);
}
try {
$cart = Cart::findOrFail($cartId);
// Cart remains in active state, user can try again
return response()->json([
'success' => true,
'message' => 'Payment cancelled',
'cart_id' => $cart->id,
]);
} catch (\Exception $e) {
Log::error('Stripe cancel handler failed', [
'cart_id' => $cartId,
'error' => $e->getMessage(),
]);
return response()->json([
'error' => 'Failed to process cancellation: ' . $e->getMessage()
], 500);
}
}
}

View File

@ -0,0 +1,270 @@
<?php
namespace Blax\Shop\Http\Controllers;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Enums\PurchaseStatus;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;
class StripeWebhookController
{
public function __construct()
{
if (config('shop.stripe.enabled')) {
Stripe::setApiKey(config('services.stripe.secret'));
}
}
/**
* Handle Stripe webhook events
*/
public function handleWebhook(Request $request)
{
if (!config('shop.stripe.enabled')) {
return response()->json(['error' => 'Stripe is not enabled'], 400);
}
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
$webhookSecret = config('services.stripe.webhook_secret');
try {
// Verify webhook signature
if ($webhookSecret) {
$event = Webhook::constructEvent($payload, $sigHeader, $webhookSecret);
} else {
// If no webhook secret, parse the event directly (not recommended for production)
$event = json_decode($payload);
}
} catch (\UnexpectedValueException $e) {
Log::error('Stripe webhook invalid payload', ['error' => $e->getMessage()]);
return response()->json(['error' => 'Invalid payload'], 400);
} catch (SignatureVerificationException $e) {
Log::error('Stripe webhook signature verification failed', ['error' => $e->getMessage()]);
return response()->json(['error' => 'Invalid signature'], 400);
}
// Handle the event
try {
switch ($event->type) {
case 'checkout.session.completed':
$this->handleCheckoutSessionCompleted($event->data->object);
break;
case 'checkout.session.async_payment_succeeded':
$this->handleCheckoutSessionCompleted($event->data->object);
break;
case 'checkout.session.async_payment_failed':
$this->handleCheckoutSessionFailed($event->data->object);
break;
case 'charge.succeeded':
$this->handleChargeSucceeded($event->data->object);
break;
case 'charge.failed':
$this->handleChargeFailed($event->data->object);
break;
case 'payment_intent.succeeded':
$this->handlePaymentIntentSucceeded($event->data->object);
break;
case 'payment_intent.payment_failed':
$this->handlePaymentIntentFailed($event->data->object);
break;
default:
Log::info('Stripe webhook unhandled event type', ['type' => $event->type]);
}
return response()->json(['success' => true]);
} catch (\Exception $e) {
Log::error('Stripe webhook handler failed', [
'type' => $event->type,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json(['error' => 'Webhook handler failed'], 500);
}
}
/**
* Handle checkout.session.completed event
*/
protected function handleCheckoutSessionCompleted($session)
{
$cartId = $session->metadata->cart_id ?? $session->client_reference_id;
if (!$cartId) {
Log::warning('Stripe checkout session completed without cart ID', ['session_id' => $session->id]);
return;
}
$cart = Cart::find($cartId);
if (!$cart) {
Log::warning('Stripe checkout session for non-existent cart', ['cart_id' => $cartId]);
return;
}
// Only update if not already converted
if ($cart->status !== CartStatus::CONVERTED) {
$cart->update([
'status' => CartStatus::CONVERTED,
'converted_at' => now(),
]);
// Update associated purchases
$this->updatePurchasesForSession($cart, $session);
Log::info('Cart converted via Stripe checkout', [
'cart_id' => $cart->id,
'session_id' => $session->id,
]);
}
}
/**
* Handle checkout.session failed event
*/
protected function handleCheckoutSessionFailed($session)
{
$cartId = $session->metadata->cart_id ?? $session->client_reference_id;
if (!$cartId) {
Log::warning('Stripe checkout session failed without cart ID', ['session_id' => $session->id]);
return;
}
Log::info('Stripe checkout session failed', [
'cart_id' => $cartId,
'session_id' => $session->id,
]);
// Cart remains in active state for retry
}
/**
* Handle charge.succeeded event
*/
protected function handleChargeSucceeded($charge)
{
Log::info('Stripe charge succeeded', [
'charge_id' => $charge->id,
'amount' => $charge->amount,
]);
// Update purchases with this charge ID if they exist
$purchases = ProductPurchase::where('charge_id', $charge->id)->get();
foreach ($purchases as $purchase) {
$updateData = [
'status' => PurchaseStatus::COMPLETED,
];
if (in_array('amount_paid', $purchase->getFillable())) {
$updateData['amount_paid'] = $charge->amount / 100;
}
$purchase->update($updateData);
}
}
/**
* Handle charge.failed event
*/
protected function handleChargeFailed($charge)
{
Log::warning('Stripe charge failed', [
'charge_id' => $charge->id,
'failure_message' => $charge->failure_message ?? 'Unknown error',
]);
// Update purchases with this charge ID
$purchases = ProductPurchase::where('charge_id', $charge->id)->get();
foreach ($purchases as $purchase) {
$purchase->update([
'status' => PurchaseStatus::FAILED,
]);
}
}
/**
* Handle payment_intent.succeeded event
*/
protected function handlePaymentIntentSucceeded($paymentIntent)
{
Log::info('Stripe payment intent succeeded', [
'payment_intent_id' => $paymentIntent->id,
'amount' => $paymentIntent->amount,
]);
// Update purchases with this payment intent
$purchases = ProductPurchase::where('charge_id', $paymentIntent->id)->get();
foreach ($purchases as $purchase) {
$updateData = [
'status' => PurchaseStatus::COMPLETED,
];
if (in_array('amount_paid', $purchase->getFillable())) {
$updateData['amount_paid'] = $paymentIntent->amount / 100;
}
$purchase->update($updateData);
}
}
/**
* Handle payment_intent.payment_failed event
*/
protected function handlePaymentIntentFailed($paymentIntent)
{
Log::warning('Stripe payment intent failed', [
'payment_intent_id' => $paymentIntent->id,
'last_payment_error' => $paymentIntent->last_payment_error->message ?? 'Unknown error',
]);
$purchases = ProductPurchase::where('charge_id', $paymentIntent->id)->get();
foreach ($purchases as $purchase) {
$purchase->update([
'status' => PurchaseStatus::FAILED,
]);
}
}
/**
* Update product purchases for a checkout session
*/
protected function updatePurchasesForSession(Cart $cart, $session)
{
$purchases = $cart->items()->with('purchase')->get()->pluck('purchase')->filter();
foreach ($purchases as $purchase) {
if (!$purchase) {
continue;
}
$updateData = [
'status' => PurchaseStatus::COMPLETED,
];
// Only update columns that exist in the model
if (in_array('charge_id', $purchase->getFillable())) {
$updateData['charge_id'] = $session->payment_intent;
}
if (in_array('amount_paid', $purchase->getFillable())) {
$updateData['amount_paid'] = $session->amount_total / 100; // Convert from cents
}
$purchase->update($updateData);
}
}
}

View File

@ -6,6 +6,7 @@ use Blax\Shop\Contracts\Cartable;
use Blax\Shop\Enums\CartStatus; use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Workkit\Traits\HasExpiration; use Blax\Workkit\Traits\HasExpiration;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -200,6 +201,14 @@ class Cart extends Model
throw new \Exception("Item must implement the Cartable interface."); throw new \Exception("Item must implement the Cartable interface.");
} }
// Extract dates from parameters if not provided directly
if (!$from && isset($parameters['from'])) {
$from = is_string($parameters['from']) ? Carbon::parse($parameters['from']) : $parameters['from'];
}
if (!$until && isset($parameters['until'])) {
$until = is_string($parameters['until']) ? Carbon::parse($parameters['until']) : $parameters['until'];
}
// Validate Product-specific requirements // Validate Product-specific requirements
if ($cartable instanceof Product) { if ($cartable instanceof Product) {
// Validate pricing before adding to cart // Validate pricing before adding to cart
@ -222,7 +231,8 @@ class Cart extends Model
// Check pool product availability if dates are provided // Check pool product availability if dates are provided
if ($cartable->isPool()) { if ($cartable->isPool()) {
$maxQuantity = $cartable->getPoolMaxQuantity($from, $until); $maxQuantity = $cartable->getPoolMaxQuantity($from, $until);
if ($quantity > $maxQuantity) { // Only validate if pool has limited availability
if ($maxQuantity !== PHP_INT_MAX && $quantity > $maxQuantity) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException( throw new \Blax\Shop\Exceptions\NotEnoughStockException(
"Pool product '{$cartable->name}' has only {$maxQuantity} items available for the requested period ({$from->format('Y-m-d')} to {$until->format('Y-m-d')}). Requested: {$quantity}" "Pool product '{$cartable->name}' has only {$maxQuantity} items available for the requested period ({$from->format('Y-m-d')} to {$until->format('Y-m-d')}). Requested: {$quantity}"
); );
@ -231,15 +241,37 @@ class Cart extends Model
} elseif ($from || $until) { } elseif ($from || $until) {
// If only one date is provided, it's an error // If only one date is provided, it's an error
throw new \Exception("Both 'from' and 'until' dates must be provided together, or both omitted."); throw new \Exception("Both 'from' and 'until' dates must be provided together, or both omitted.");
} else {
// Even without dates, check pool quantity limits
if ($cartable->isPool()) {
$maxQuantity = $cartable->getPoolMaxQuantity();
// Skip validation if pool has unlimited availability
if ($maxQuantity !== PHP_INT_MAX) {
// Get current quantity in cart for this pool product
$currentQuantityInCart = $this->items()
->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable))
->sum('quantity');
$totalQuantity = $currentQuantityInCart + $quantity;
if ($totalQuantity > $maxQuantity) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
"Pool product '{$cartable->name}' has only {$maxQuantity} items available. Already in cart: {$currentQuantityInCart}, Requested: {$quantity}"
);
}
}
}
} }
} }
// Check if item already exists in cart with same parameters and dates // Check if item already exists in cart with same parameters, dates, AND price
$existingItem = $this->items() $existingItem = $this->items()
->where('purchasable_id', $cartable->getKey()) ->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable)) ->where('purchasable_type', get_class($cartable))
->get() ->get()
->first(function ($item) use ($parameters, $from, $until) { ->first(function ($item) use ($parameters, $from, $until, $cartable) {
$existingParams = is_array($item->parameters) $existingParams = is_array($item->parameters)
? $item->parameters ? $item->parameters
: (array) $item->parameters; : (array) $item->parameters;
@ -260,7 +292,20 @@ class Cart extends Model
); );
} }
return $paramsMatch && $datesMatch; // For pool products, also check if price matches
// Different prices mean different availability/items, so separate cart items
$priceMatch = true;
if ($cartable instanceof Product && $cartable->isPool()) {
$currentPrice = $cartable->getCurrentPrice();
if ($from && $until) {
$days = max(1, $from->diff($until)->days);
$currentPrice *= $days;
}
// Compare with small delta to account for floating point precision
$priceMatch = abs((float)$item->price - $currentPrice) < 0.01;
}
return $paramsMatch && $datesMatch && $priceMatch;
}); });
// Calculate price per day (base price) // Calculate price per day (base price)
@ -347,7 +392,12 @@ class Cart extends Model
return $item ?? true; return $item ?? true;
} }
public function checkout(): static /**
* Validate cart for checkout without converting it
*
* @throws \Exception
*/
public function validateForCheckout(): void
{ {
$items = $this->items() $items = $this->items()
->with('purchasable') ->with('purchasable')
@ -357,6 +407,27 @@ class Cart extends Model
throw new \Exception("Cart is empty"); throw new \Exception("Cart is empty");
} }
// Validate that all items have required information before checkout
foreach ($items as $item) {
$adjustments = $item->requiredAdjustments();
if (!empty($adjustments)) {
$product = $item->purchasable;
$productName = $product ? $product->name : 'Unknown Product';
$missingFields = implode(', ', array_keys($adjustments));
throw new \Exception("Cart item '{$productName}' is missing required information: {$missingFields}");
}
}
}
public function checkout(): static
{
// Validate cart before proceeding
$this->validateForCheckout();
$items = $this->items()
->with('purchasable')
->get();
// Create ProductPurchase for each cart item // Create ProductPurchase for each cart item
foreach ($items as $item) { foreach ($items as $item) {
$product = $item->purchasable; $product = $item->purchasable;
@ -438,4 +509,130 @@ class Cart extends Model
return $this; return $this;
} }
/**
* Create a Stripe Checkout Session for this cart
*
* This method:
* - Validates the cart (doesn't convert it)
* - Syncs products/prices to Stripe (creates them if they don't exist)
* - Creates line items with descriptions including booking dates
* - Returns the Stripe checkout session
*
* @param array $options Optional session parameters (success_url, cancel_url, etc.)
* @return \Stripe\Checkout\Session
* @throws \Exception
*/
public function checkoutSession(array $options = []): \Stripe\Checkout\Session
{
if (!config('shop.stripe.enabled')) {
throw new \Exception('Stripe is not enabled');
}
// Ensure Stripe is initialized
\Stripe\Stripe::setApiKey(config('services.stripe.secret'));
// Validate cart before proceeding (doesn't convert it)
$this->validateForCheckout();
$syncService = new \Blax\Shop\Services\StripeSyncService();
$lineItems = [];
foreach ($this->items as $item) {
$purchasable = $item->purchasable;
// Get the price model
if ($purchasable instanceof Product) {
$price = $purchasable->defaultPrice()->first();
$product = $purchasable;
} elseif ($purchasable instanceof \Blax\Shop\Models\ProductPrice) {
$price = $purchasable;
$product = $purchasable->purchasable;
} else {
throw new \Exception("Item has no valid price");
}
if (!$price) {
$name = $purchasable->name ?? 'Unknown item';
throw new \Exception("Item '{$name}' has no default price");
}
// Sync product and price to Stripe
$stripePriceId = $syncService->syncPrice($price, $product);
// Build line item with description including booking dates if applicable
$lineItem = [
'price' => $stripePriceId,
'quantity' => $item->quantity,
];
// Add description with booking dates if available
$description = null;
if ($item->from && $item->until) {
$days = max(1, $item->from->diffInDays($item->until));
$fromFormatted = $item->from->format('M j, Y');
$untilFormatted = $item->until->format('M j, Y');
$description = "Period: {$fromFormatted} to {$untilFormatted} ({$days} day" . ($days > 1 ? 's' : '') . ")";
}
if ($description) {
$lineItem['description'] = $description;
}
$lineItems[] = $lineItem;
}
// Prepare session parameters
$sessionParams = [
'payment_method_types' => ['card'],
'line_items' => $lineItems,
'mode' => 'payment',
'success_url' => $options['success_url'] ?? route('shop.stripe.success') . '?session_id={CHECKOUT_SESSION_ID}&cart_id=' . $this->id,
'cancel_url' => $options['cancel_url'] ?? route('shop.stripe.cancel') . '?cart_id=' . $this->id,
'client_reference_id' => $this->id,
'metadata' => array_merge([
'cart_id' => $this->id,
], $options['metadata'] ?? []),
];
// Add customer email if available
if ($this->customer) {
if (method_exists($this->customer, 'email')) {
$sessionParams['customer_email'] = $this->customer->email;
} elseif (isset($this->customer->email)) {
$sessionParams['customer_email'] = $this->customer->email;
}
}
// Allow custom session parameters
if (isset($options['session_params'])) {
$sessionParams = array_merge($sessionParams, $options['session_params']);
}
try {
$session = \Stripe\Checkout\Session::create($sessionParams);
// Store session ID in cart meta
$meta = $this->meta ?? (object)[];
if (is_array($meta)) {
$meta = (object)$meta;
}
$meta->stripe_session_id = $session->id;
$this->update(['meta' => $meta]);
\Illuminate\Support\Facades\Log::info('Stripe checkout session created', [
'cart_id' => $this->id,
'session_id' => $session->id,
]);
return $session;
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('Stripe checkout session creation failed', [
'cart_id' => $this->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
} }

View File

@ -168,4 +168,91 @@ class CartItem extends Model
return $adjustments; return $adjustments;
} }
/**
* Update the booking dates for this cart item.
* Automatically recalculates price based on the new date range.
*
* @param \DateTimeInterface $from Start date
* @param \DateTimeInterface $until End date
* @return $this
* @throws \Exception If dates are invalid
*/
public function updateDates(\DateTimeInterface $from, \DateTimeInterface $until): self
{
if ($from >= $until) {
throw new \Exception("The 'from' date must be before the 'until' date.");
}
$product = $this->purchasable;
if (!$product || !($product instanceof Product)) {
throw new \Exception("Cannot update dates for non-product items.");
}
// Calculate days
$days = max(1, $from->diff($until)->days);
// Get current price per day
$pricePerDay = $product->getCurrentPrice();
$regularPricePerDay = $product->getCurrentPrice(false) ?? $pricePerDay;
// Calculate new prices
$pricePerUnit = $pricePerDay * $days;
$regularPricePerUnit = $regularPricePerDay * $days;
$this->update([
'from' => $from,
'until' => $until,
'price' => $pricePerUnit,
'regular_price' => $regularPricePerUnit,
'subtotal' => $pricePerUnit * $this->quantity,
]);
return $this->fresh();
}
/**
* Set the 'from' date for this cart item.
*
* @param \DateTimeInterface $from Start date
* @return $this
*/
public function setFromDate(\DateTimeInterface $from): self
{
if ($this->until && $from >= $this->until) {
throw new \Exception("The 'from' date must be before the 'until' date.");
}
$this->update(['from' => $from]);
// If both dates are now set, recalculate pricing
if ($this->until) {
return $this->updateDates($from, $this->until);
}
return $this->fresh();
}
/**
* Set the 'until' date for this cart item.
*
* @param \DateTimeInterface $until End date
* @return $this
*/
public function setUntilDate(\DateTimeInterface $until): self
{
if ($this->from && $this->from >= $until) {
throw new \Exception("The 'until' date must be after the 'from' date.");
}
$this->update(['until' => $until]);
// If both dates are now set, recalculate pricing
if ($this->from) {
return $this->updateDates($this->from, $until);
}
return $this->fresh();
}
} }

View File

@ -9,6 +9,7 @@ use Blax\Shop\Events\ProductUpdated;
use Blax\Shop\Contracts\Purchasable; use Blax\Shop\Contracts\Purchasable;
use Blax\Shop\Enums\ProductStatus; use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType; use Blax\Shop\Enums\StockType;
use Blax\Shop\Exceptions\HasNoDefaultPriceException; use Blax\Shop\Exceptions\HasNoDefaultPriceException;
@ -19,6 +20,7 @@ use Blax\Shop\Traits\HasCategories;
use Blax\Shop\Traits\HasPrices; use Blax\Shop\Traits\HasPrices;
use Blax\Shop\Traits\HasProductRelations; use Blax\Shop\Traits\HasProductRelations;
use Blax\Shop\Traits\HasStocks; use Blax\Shop\Traits\HasStocks;
use Blax\Shop\Traits\MayBePoolProduct;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -28,7 +30,7 @@ use Illuminate\Support\Facades\Cache;
class Product extends Model implements Purchasable, Cartable class Product extends Model implements Purchasable, Cartable
{ {
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasCategories, HasProductRelations; use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasCategories, HasProductRelations, MayBePoolProduct;
protected $fillable = [ protected $fillable = [
'slug', 'slug',
@ -316,148 +318,6 @@ class Product extends Model implements Purchasable, Cartable
return $this->type === ProductType::BOOKING; return $this->type === ProductType::BOOKING;
} }
/**
* Check if this is a pool product
*/
public function isPool(): bool
{
return $this->type === ProductType::POOL;
}
/**
* Get the maximum available quantity for a pool product based on single items
*/
public function getPoolMaxQuantity(\DateTimeInterface $from = null, \DateTimeInterface $until = null): int
{
if (!$this->isPool()) {
return $this->getAvailableStock();
}
$singleItems = $this->singleProducts;
if ($singleItems->isEmpty()) {
return 0;
}
// If no dates provided, return the count of single items
if (!$from || !$until) {
return $singleItems->count();
}
// Check availability for each single item during the timespan
$availableCount = 0;
foreach ($singleItems as $item) {
if ($item->isAvailableForBooking($from, $until, 1)) {
$availableCount++;
}
}
return $availableCount;
}
/**
* Claim stock for a pool product
* This will claim stock from the available single items
*
* @param int $quantity Number of pool items to claim
* @param mixed $reference Reference model
* @param \DateTimeInterface|null $from Start date
* @param \DateTimeInterface|null $until End date
* @param string|null $note Optional note
* @return array Array of claimed single item products
* @throws \Exception
*/
public function claimPoolStock(
int $quantity,
$reference = null,
?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null,
?string $note = null
): array {
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
}
$singleItems = $this->singleProducts;
if ($singleItems->isEmpty()) {
throw new \Exception('Pool product has no single items to claim');
}
// Get available single items for the period
$availableItems = [];
foreach ($singleItems as $item) {
if ($item->isAvailableForBooking($from, $until, 1)) {
$availableItems[] = $item;
}
if (count($availableItems) >= $quantity) {
break;
}
}
if (count($availableItems) < $quantity) {
throw new \Exception("Only " . count($availableItems) . " items available, but {$quantity} requested");
}
// Claim stock from each selected single item
$claimedItems = [];
foreach (array_slice($availableItems, 0, $quantity) as $item) {
$item->claimStock(1, $reference, $from, $until, $note);
$claimedItems[] = $item;
}
return $claimedItems;
}
/**
* Release pool stock claims
*
* @param mixed $reference Reference model used when claiming
* @return int Number of claims released
*/
public function releasePoolStock($reference): int
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
}
$singleItems = $this->singleProducts;
$released = 0;
foreach ($singleItems as $item) {
$referenceType = is_object($reference) ? get_class($reference) : null;
$referenceId = is_object($reference) ? $reference->id : null;
// Find and delete claims for this reference
$claims = $item->stocks()
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
->where('reference_type', $referenceType)
->where('reference_id', $referenceId)
->get();
foreach ($claims as $claim) {
$claim->release();
$released++;
}
}
return $released;
}
/**
* Check if any single item in pool is a booking product
*/
public function hasBookingSingleItems(): bool
{
if (!$this->isPool()) {
return false;
}
return $this->singleProducts()->where('products.type', ProductType::BOOKING->value)->exists();
}
/** /**
* Check stock availability for a booking period * Check stock availability for a booking period
*/ */
@ -511,205 +371,15 @@ class Product extends Model implements Purchasable, Cartable
*/ */
public function getCurrentPrice(bool|null $sales_price = null): ?float 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 is a pool product, use the trait method
if ($this->isPool() && !$this->hasPrice()) { if ($this->isPool()) {
return $this->getInheritedPoolPrice($sales_price); return $this->getPoolCurrentPrice($sales_price);
}
// 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 // For non-pool products, use the trait's default behavior
return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale()); return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
} }
/**
* Get inherited price from single items based on pricing strategy
*/
protected function getInheritedPoolPrice(bool|null $sales_price = null): ?float
{
if (!$this->isPool()) {
return null;
}
$strategy = $this->getPoolPricingStrategy();
$singleItems = $this->singleProducts;
if ($singleItems->isEmpty()) {
return null;
}
$prices = $singleItems->map(function ($item) use ($sales_price) {
return $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $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 * Validate booking product configuration and provide helpful error messages
* *

View File

@ -0,0 +1,197 @@
<?php
namespace Blax\Shop\Services;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\Product as StripeProduct;
use Stripe\Price as StripePrice;
class StripeSyncService
{
public function __construct()
{
if (config('shop.stripe.enabled')) {
Stripe::setApiKey(config('services.stripe.secret'));
}
}
/**
* Sync a product to Stripe and return the Stripe product ID
*
* @param Product $product
* @return string Stripe Product ID
*/
public function syncProduct(Product $product): string
{
if (!config('shop.stripe.enabled')) {
throw new \Exception('Stripe is not enabled');
}
// Check if product already has a Stripe ID
if ($product->stripe_product_id) {
try {
// Verify the product still exists in Stripe
StripeProduct::retrieve($product->stripe_product_id);
// Update the product in Stripe
StripeProduct::update($product->stripe_product_id, [
'name' => $product->name,
'description' => $product->short_description ?? $product->description,
'active' => $product->status === \Blax\Shop\Enums\ProductStatus::PUBLISHED,
'metadata' => [
'product_id' => $product->id,
'sku' => $product->sku,
],
]);
return $product->stripe_product_id;
} catch (\Stripe\Exception\InvalidRequestException $e) {
// Product doesn't exist in Stripe, create a new one
Log::warning('Stripe product not found, creating new one', [
'product_id' => $product->id,
'stripe_product_id' => $product->stripe_product_id,
]);
}
}
// Create new Stripe product
$stripeProduct = StripeProduct::create([
'name' => $product->name,
'description' => $product->short_description ?? $product->description,
'active' => $product->status === \Blax\Shop\Enums\ProductStatus::PUBLISHED,
'metadata' => [
'product_id' => $product->id,
'sku' => $product->sku,
],
]);
// Update local product with Stripe ID
$product->update(['stripe_product_id' => $stripeProduct->id]);
Log::info('Product synced to Stripe', [
'product_id' => $product->id,
'stripe_product_id' => $stripeProduct->id,
]);
return $stripeProduct->id;
}
/**
* Sync a product price to Stripe and return the Stripe price ID
*
* @param ProductPrice $price
* @param Product|null $product
* @return string Stripe Price ID
*/
public function syncPrice(ProductPrice $price, ?Product $product = null): string
{
if (!config('shop.stripe.enabled')) {
throw new \Exception('Stripe is not enabled');
}
// Get the product if not provided
if (!$product && $price->purchasable instanceof Product) {
$product = $price->purchasable;
}
if (!$product) {
throw new \Exception('Cannot sync price without associated product');
}
// Ensure product is synced to Stripe
$stripeProductId = $this->syncProduct($product);
// Check if price already has a Stripe ID
if ($price->stripe_price_id) {
try {
// Verify the price still exists in Stripe
$stripePrice = StripePrice::retrieve($price->stripe_price_id);
// Check if price parameters match
$unitAmount = (int) ($price->unit_amount * 100); // Convert to cents
if (
$stripePrice->unit_amount === $unitAmount &&
$stripePrice->currency === strtolower($price->currency)
) {
return $price->stripe_price_id;
}
// Price parameters changed, need to create a new price
// (Stripe prices are immutable)
Log::info('Price parameters changed, creating new Stripe price', [
'price_id' => $price->id,
'old_stripe_price_id' => $price->stripe_price_id,
]);
} catch (\Stripe\Exception\InvalidRequestException $e) {
// Price doesn't exist in Stripe, create a new one
Log::warning('Stripe price not found, creating new one', [
'price_id' => $price->id,
'stripe_price_id' => $price->stripe_price_id,
]);
}
}
// Create new Stripe price
$unitAmount = (int) ($price->unit_amount * 100); // Convert to cents
$priceParams = [
'product' => $stripeProductId,
'unit_amount' => $unitAmount,
'currency' => strtolower($price->currency),
'metadata' => [
'price_id' => $price->id,
],
];
// Add recurring parameters if applicable
if ($price->type === \Blax\Shop\Enums\PriceType::RECURRING) {
$priceParams['recurring'] = [
'interval' => $price->interval->value,
];
if ($price->interval_count && $price->interval_count > 1) {
$priceParams['recurring']['interval_count'] = $price->interval_count;
}
}
$stripePrice = StripePrice::create($priceParams);
// Update local price with Stripe ID
$price->update(['stripe_price_id' => $stripePrice->id]);
Log::info('Price synced to Stripe', [
'price_id' => $price->id,
'stripe_price_id' => $stripePrice->id,
]);
return $stripePrice->id;
}
/**
* Sync product and its default price to Stripe
*
* @param Product $product
* @return array ['product_id' => string, 'price_id' => string]
*/
public function syncProductWithPrice(Product $product): array
{
$stripeProductId = $this->syncProduct($product);
$defaultPrice = $product->defaultPrice()->first();
if (!$defaultPrice) {
throw new \Exception("Product '{$product->name}' has no default price");
}
$stripePriceId = $this->syncPrice($defaultPrice, $product);
return [
'product_id' => $stripeProductId,
'price_id' => $stripePriceId,
];
}
}

View File

@ -57,6 +57,11 @@ trait HasProductRelations
return $this->relationsByType(ProductRelationType::SINGLE); return $this->relationsByType(ProductRelationType::SINGLE);
} }
public function poolProducts(): BelongsToMany
{
return $this->relationsByType(ProductRelationType::POOL);
}
public function relationsByType(ProductRelationType|string $type): BelongsToMany public function relationsByType(ProductRelationType|string $type): BelongsToMany
{ {
$typeValue = $type instanceof ProductRelationType ? $type->value : $type; $typeValue = $type instanceof ProductRelationType ? $type->value : $type;

View File

@ -0,0 +1,655 @@
<?php
namespace Blax\Shop\Traits;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Exceptions\InvalidPoolConfigurationException;
trait MayBePoolProduct
{
/**
* Check if this is a pool product
*/
public function isPool(): bool
{
return $this->type === ProductType::POOL;
}
/**
* Get the available quantity for this product
* For pool products, returns the count of available single items
* For regular products, returns available stock
*/
public function getAvailableQuantity(\DateTimeInterface $from = null, \DateTimeInterface $until = null): int
{
if ($this->isPool()) {
return $this->getPoolMaxQuantity($from, $until);
}
return $this->getAvailableStock();
}
/**
* 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, sum up available stock from all single items
if (!$from || !$until) {
$hasUnlimitedItem = false;
$total = 0;
foreach ($singleItems as $item) {
if (!$item->manage_stock) {
// Track if there's an unlimited item, but don't count it
$hasUnlimitedItem = true;
continue;
}
$total += $item->getAvailableStock();
}
// If ALL items are unlimited, pool is unlimited
if ($hasUnlimitedItem && $total === 0) {
return PHP_INT_MAX;
}
return $total;
}
// Check availability for each single item during the timespan and sum their available quantities
$availableCount = 0;
$hasUnlimitedItem = false;
foreach ($singleItems as $item) {
// Track unlimited items but don't count them
if (!$item->manage_stock) {
$hasUnlimitedItem = true;
continue;
}
// For booking items, check how many units are available for the period
if ($item->isBooking()) {
$availableStock = $item->getAvailableStock();
// Check if any quantity is available for booking
for ($qty = $availableStock; $qty > 0; $qty--) {
if ($item->isAvailableForBooking($from, $until, $qty)) {
$availableCount += $qty;
break;
}
}
} else {
// For non-booking items, just add their available stock
$availableCount += $item->getAvailableStock();
}
}
// If ALL items are unlimited, pool is unlimited
if ($hasUnlimitedItem && $availableCount === 0) {
return PHP_INT_MAX;
}
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();
}
/**
* Get the current price with pool product inheritance support
*/
public function getPoolCurrentPrice(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($sales_price);
}
// If pool has a direct price, use it
if ($this->isPool() && $this->hasPrice()) {
return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
}
return null;
}
/**
* Get inherited price from single items based on pricing strategy
*/
protected function getInheritedPoolPrice(bool|null $sales_price = null): ?float
{
if (!$this->isPool()) {
return null;
}
$strategy = $this->getPoolPricingStrategy();
$singleItems = $this->singleProducts;
if ($singleItems->isEmpty()) {
return null;
}
$prices = $singleItems->map(function ($item) use ($sales_price) {
return $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $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();
}
/**
* Attach single items to this pool product
* Also creates reverse POOL relation from single items back to this pool
*
* @param array|int|string $singleItemIds Single product ID(s) to attach
* @param array $attributes Additional pivot attributes
* @return void
*/
public function attachSingleItems(array|int|string $singleItemIds, array $attributes = []): void
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
}
$ids = is_array($singleItemIds) ? $singleItemIds : [$singleItemIds];
// Attach single items to pool with SINGLE type
$this->productRelations()->attach(
array_fill_keys($ids, array_merge(['type' => ProductRelationType::SINGLE->value], $attributes))
);
// Attach reverse POOL relation from each single item back to this pool
foreach ($ids as $singleItemId) {
$singleItem = static::find($singleItemId);
if ($singleItem) {
$singleItem->productRelations()->attach(
$this->id,
array_merge(['type' => ProductRelationType::POOL->value], $attributes)
);
}
}
}
/**
* 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,
];
}
/**
* Get pool availability calendar showing how many items are available on each date.
* Returns an array with dates as keys and availability counts as values.
*
* Example usage:
* ```php
* $pool = Product::find($id);
* $availability = $pool->getPoolAvailabilityCalendar('2025-01-01', '2025-01-07', 2);
*
* foreach ($availability as $date => $count) {
* echo "$date: $count items available\n";
* }
* // Output:
* // 2025-01-01: 3 items available
* // 2025-01-02: 2 items available
* // 2025-01-03: 1 items available
* ```
*
* @param \DateTimeInterface|string $startDate Start date for availability check
* @param \DateTimeInterface|string $endDate End date for availability check
* @param int $quantity How many items are needed (default 1)
* @return array Array with dates as keys and availability counts as values
*/
public function getPoolAvailabilityCalendar($startDate, $endDate, int $quantity = 1): array
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
}
$start = $startDate instanceof \DateTimeInterface ? $startDate : \Carbon\Carbon::parse($startDate);
$end = $endDate instanceof \DateTimeInterface ? $endDate : \Carbon\Carbon::parse($endDate);
$calendar = [];
$current = $start->copy();
while ($current <= $end) {
$dateStr = $current->format('Y-m-d');
$nextDay = $current->copy()->addDay();
// Check availability for this single day
$available = $this->getPoolMaxQuantity($current, $nextDay);
$calendar[$dateStr] = $available === PHP_INT_MAX ? 'unlimited' : $available;
$current->addDay();
}
return $calendar;
}
/**
* Get detailed availability for each single item in the pool.
* Shows which specific items are available and their quantities.
*
* Example usage:
* ```php
* $pool = Product::find($id);
* $items = $pool->getSingleItemsAvailability('2025-01-01', '2025-01-02');
*
* foreach ($items as $item) {
* echo "{$item['name']}: {$item['available']} available\n";
* }
* ```
*
* @param \DateTimeInterface|string|null $from Start date (optional)
* @param \DateTimeInterface|string|null $until End date (optional)
* @return array Array of single items with their availability
*/
public function getSingleItemsAvailability($from = null, $until = null): array
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
}
$singleItems = $this->singleProducts;
$availability = [];
if ($from && $until) {
$fromDate = $from instanceof \DateTimeInterface ? $from : \Carbon\Carbon::parse($from);
$untilDate = $until instanceof \DateTimeInterface ? $until : \Carbon\Carbon::parse($until);
}
foreach ($singleItems as $item) {
$available = 0;
if (!$item->manage_stock) {
$available = 'unlimited';
} elseif (isset($fromDate) && isset($untilDate)) {
// Check availability for the specific period
if ($item->isBooking()) {
$availableStock = $item->getAvailableStock();
// Check maximum available quantity for this period
for ($qty = $availableStock; $qty > 0; $qty--) {
if ($item->isAvailableForBooking($fromDate, $untilDate, $qty)) {
$available = $qty;
break;
}
}
} else {
$available = $item->getAvailableStock();
}
} else {
// No dates specified, get general stock
$available = $item->getAvailableStock();
}
$availability[] = [
'id' => $item->id,
'name' => $item->name,
'type' => $item->type->value,
'available' => $available,
'manage_stock' => $item->manage_stock,
];
}
return $availability;
}
/**
* Check if the pool is available for a specific date range and quantity.
* A pool is NOT available if at least one single item is not available.
*
* @param \DateTimeInterface $from Start date
* @param \DateTimeInterface $until End date
* @param int $quantity Required quantity
* @return bool True if pool is available for the period
*/
public function isPoolAvailable(\DateTimeInterface $from, \DateTimeInterface $until, int $quantity = 1): bool
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
}
$maxQuantity = $this->getPoolMaxQuantity($from, $until);
// Unlimited availability
if ($maxQuantity === PHP_INT_MAX) {
return true;
}
return $maxQuantity >= $quantity;
}
/**
* Get available date ranges for the pool with a specific quantity.
* Returns periods where the pool has the required availability.
*
* @param \DateTimeInterface|string $startDate Start of search period
* @param \DateTimeInterface|string $endDate End of search period
* @param int $quantity Required quantity
* @param int $minConsecutiveDays Minimum consecutive days needed (default 1)
* @return array Array of available periods
*/
public function getPoolAvailablePeriods($startDate, $endDate, int $quantity = 1, int $minConsecutiveDays = 1): array
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
}
$start = $startDate instanceof \DateTimeInterface ? $startDate : \Carbon\Carbon::parse($startDate);
$end = $endDate instanceof \DateTimeInterface ? $endDate : \Carbon\Carbon::parse($endDate);
$calendar = $this->getPoolAvailabilityCalendar($start, $end, $quantity);
$periods = [];
$currentPeriod = null;
foreach ($calendar as $date => $available) {
$isAvailable = ($available === 'unlimited' || $available >= $quantity);
if ($isAvailable) {
if ($currentPeriod === null) {
$currentPeriod = [
'from' => $date,
'until' => $date,
'min_available' => $available,
];
} else {
$currentPeriod['until'] = $date;
if ($available !== 'unlimited' && $currentPeriod['min_available'] !== 'unlimited') {
$currentPeriod['min_available'] = min($currentPeriod['min_available'], $available);
}
}
} else {
if ($currentPeriod !== null) {
// Check if period meets minimum days requirement
$from = \Carbon\Carbon::parse($currentPeriod['from']);
$until = \Carbon\Carbon::parse($currentPeriod['until']);
$days = $from->diffInDays($until) + 1; // +1 to include both start and end dates
if ($days >= $minConsecutiveDays) {
$periods[] = $currentPeriod;
}
$currentPeriod = null;
}
}
}
// Add final period if exists
if ($currentPeriod !== null) {
$from = \Carbon\Carbon::parse($currentPeriod['from']);
$until = \Carbon\Carbon::parse($currentPeriod['until']);
$days = $from->diffInDays($until) + 1;
if ($days >= $minConsecutiveDays) {
$periods[] = $currentPeriod;
}
}
return $periods;
}
}

View File

@ -793,4 +793,203 @@ class CartAddToCartPoolPricingTest extends TestCase
$this->assertEquals($until->format('Y-m-d H:i:s'), $cartItem->until->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'));
$this->assertEquals(6000, $cartItem->price); $this->assertEquals(6000, $cartItem->price);
} }
/** @test */
public function it_limits_pool_in_cart_quantity_by_single_products()
{
// Set price on pool
ProductPrice::factory()->create([
'purchasable_id' => $this->poolProduct->id,
'purchasable_type' => Product::class,
'unit_amount' => 3000,
'currency' => 'USD',
'is_default' => true,
]);
// Assert cart is empty
$this->assertEquals(0, $this->cart->items()->count());
// Assert poolProduct has quantity availability of 2 (based on 2 single items)
$availableQuantity = $this->poolProduct->getAvailableQuantity();
$this->assertEquals(2, $availableQuantity);
// Adding 2 pool items should succeed (without dates)
$cartItem = $this->cart->addToCart($this->poolProduct, 2);
$this->assertNotNull($cartItem);
$this->assertEquals(2, $cartItem->quantity);
// Try to add 1 more (total would be 3, but only 2 available)
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
$this->expectExceptionMessage('has only 2 items available');
$this->cart->addToCart($this->poolProduct, 1);
}
/** @test */
public function it_counts_single_item_stock_quantities_in_pool_availability()
{
// Create a pool with multiple single items having different stock quantities
$pool = Product::factory()->create([
'name' => 'Large Parking Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
// Create single items with different stock quantities
$spot1 = Product::factory()->create([
'name' => 'Standard Spot',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$spot1->increaseStock(5); // 5 units available
$spot2 = Product::factory()->create([
'name' => 'Premium Spot',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$spot2->increaseStock(3); // 3 units available
$spot3 = Product::factory()->create([
'name' => 'VIP Spot',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$spot3->increaseStock(2); // 2 units available
// Attach single items to pool
$pool->attachSingleItems([$spot1->id, $spot2->id, $spot3->id]);
// Pool should have availability = 5 + 3 + 2 = 10
$availableQuantity = $pool->getAvailableQuantity();
$this->assertEquals(10, $availableQuantity);
// Set price on pool
ProductPrice::factory()->create([
'purchasable_id' => $pool->id,
'purchasable_type' => Product::class,
'unit_amount' => 3000,
'currency' => 'USD',
'is_default' => true,
]);
$cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
// Should be able to add 10 pool items
$cartItem = $cart->addToCart($pool, 10);
$this->assertNotNull($cartItem);
$this->assertEquals(10, $cartItem->quantity);
// But not 11
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
$this->expectExceptionMessage('has only 10 items available');
$cart->addToCart($pool, 1);
}
/** @test */
public function it_counts_available_stock_with_booking_dates()
{
// Create pool with single items having stock
$pool = Product::factory()->create([
'name' => 'Conference Room Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$room1 = Product::factory()->create([
'name' => 'Room A',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$room1->increaseStock(3);
$room2 = Product::factory()->create([
'name' => 'Room B',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$room2->increaseStock(2);
$pool->attachSingleItems([$room1->id, $room2->id]);
ProductPrice::factory()->create([
'purchasable_id' => $pool->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$from = Carbon::now()->addDays(1)->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
// Total availability should be 3 + 2 = 5
$this->assertEquals(5, $pool->getAvailableQuantity($from, $until));
$cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
// Should be able to book 5 pool items for the period
$cartItem = $cart->addToCart($pool, 5, [], $from, $until);
$this->assertNotNull($cartItem);
$this->assertEquals(5, $cartItem->quantity);
}
/** @test */
public function it_allows_unlimited_pool_when_single_items_dont_manage_stock()
{
// Create pool with single items that don't manage stock
$pool = Product::factory()->create([
'name' => 'Unlimited Parking Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$spot1 = Product::factory()->create([
'name' => 'Unlimited Spot 1',
'type' => ProductType::BOOKING,
'manage_stock' => false, // No stock management
]);
$spot2 = Product::factory()->create([
'name' => 'Unlimited Spot 2',
'type' => ProductType::BOOKING,
'manage_stock' => false, // No stock management
]);
$pool->attachSingleItems([$spot1->id, $spot2->id]);
ProductPrice::factory()->create([
'purchasable_id' => $pool->id,
'purchasable_type' => Product::class,
'unit_amount' => 3000,
'currency' => 'USD',
'is_default' => true,
]);
// Pool should have unlimited availability
$this->assertEquals(PHP_INT_MAX, $pool->getAvailableQuantity());
$cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
// Should be able to add any quantity without dates
$cartItem = $cart->addToCart($pool, 1000);
$this->assertNotNull($cartItem);
$this->assertEquals(1000, $cartItem->quantity);
// And with dates
$from = Carbon::now()->addDays(1)->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
$cartItem2 = $cart->addToCart($pool, 500, [], $from, $until);
$this->assertNotNull($cartItem2);
$this->assertEquals(500, $cartItem2->quantity);
}
} }

View File

@ -0,0 +1,262 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Workbench\App\Models\User;
class CartItemDateManagementTest extends TestCase
{
protected User $user;
protected Cart $cart;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
}
/** @test */
public function it_can_update_dates_on_cart_item()
{
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product->increaseStock(10);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000, // 50.00 per day
'currency' => 'USD',
'is_default' => true,
]);
// Add without dates
$cartItem = $this->cart->addToCart($product, 1);
$this->assertNull($cartItem->from);
$this->assertNull($cartItem->until);
// Update dates
$from = Carbon::now()->addDays(1)->startOfDay();
$until = Carbon::now()->addDays(4)->startOfDay(); // 3 days
$updated = $cartItem->updateDates($from, $until);
$this->assertEquals($from->format('Y-m-d H:i:s'), $updated->from->format('Y-m-d H:i:s'));
$this->assertEquals($until->format('Y-m-d H:i:s'), $updated->until->format('Y-m-d H:i:s'));
$this->assertEquals(15000, $updated->price); // 50.00 × 3 days
$this->assertEquals(15000, $updated->subtotal); // 150.00 × 1 quantity
}
/** @test */
public function it_recalculates_price_when_updating_dates()
{
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product->increaseStock(10);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 10000, // 100.00 per day
'currency' => 'USD',
'is_default' => true,
]);
$from1 = Carbon::now()->addDays(1)->startOfDay();
$until1 = Carbon::now()->addDays(3)->startOfDay(); // 2 days
$cartItem = $this->cart->addToCart($product, 2, [], $from1, $until1);
$this->assertEquals(20000, $cartItem->price); // 100 × 2 days
$this->assertEquals(40000, $cartItem->subtotal); // 200 × 2 quantity
// Update to longer period
$from2 = Carbon::now()->addDays(5)->startOfDay();
$until2 = Carbon::now()->addDays(10)->startOfDay(); // 5 days
$updated = $cartItem->updateDates($from2, $until2);
$this->assertEquals(50000, $updated->price); // 100 × 5 days
$this->assertEquals(100000, $updated->subtotal); // 500 × 2 quantity
}
/** @test */
public function it_can_set_from_date_individually()
{
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product->increaseStock(10);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$cartItem = $this->cart->addToCart($product, 1);
$from = Carbon::now()->addDays(1);
$updated = $cartItem->setFromDate($from);
$this->assertEquals($from->format('Y-m-d H:i:s'), $updated->from->format('Y-m-d H:i:s'));
$this->assertNull($updated->until);
}
/** @test */
public function it_can_set_until_date_individually()
{
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product->increaseStock(10);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$cartItem = $this->cart->addToCart($product, 1);
$until = Carbon::now()->addDays(3);
$updated = $cartItem->setUntilDate($until);
$this->assertNull($updated->from);
$this->assertEquals($until->format('Y-m-d H:i:s'), $updated->until->format('Y-m-d H:i:s'));
}
/** @test */
public function it_recalculates_when_both_dates_are_set()
{
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product->increaseStock(10);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 8000, // 80.00 per day
'currency' => 'USD',
'is_default' => true,
]);
$cartItem = $this->cart->addToCart($product, 1);
$from = Carbon::now()->addDays(1)->startOfDay();
$until = Carbon::now()->addDays(5)->startOfDay(); // 4 days
// Set from first
$cartItem->setFromDate($from);
$this->assertNull($cartItem->fresh()->until);
$this->assertEquals(8000, $cartItem->fresh()->price); // Still default 1 day
// Set until - should trigger recalculation
$updated = $cartItem->setUntilDate($until);
$this->assertEquals(32000, $updated->price); // 80 × 4 days
$this->assertEquals(32000, $updated->subtotal);
}
/** @test */
public function it_throws_exception_when_from_is_after_until()
{
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product->increaseStock(10);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$cartItem = $this->cart->addToCart($product, 1);
$from = Carbon::now()->addDays(5);
$until = Carbon::now()->addDays(2);
$this->expectException(\Exception::class);
$this->expectExceptionMessage("'from' date must be before the 'until' date");
$cartItem->updateDates($from, $until);
}
/** @test */
public function it_validates_dates_at_checkout_for_booking_products()
{
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product->increaseStock(10);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
// Add booking product without dates
$this->cart->addToCart($product, 1);
// Should throw exception at checkout
$this->expectException(\Exception::class);
$this->expectExceptionMessage('missing required information: from, until');
$this->cart->checkout();
}
/** @test */
public function it_allows_checkout_when_dates_are_set()
{
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product->increaseStock(10);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$from = Carbon::now()->addDays(1);
$until = Carbon::now()->addDays(3);
$this->cart->addToCart($product, 1, [], $from, $until);
// Should not throw exception
$cart = $this->cart->checkout();
$this->assertNotNull($cart);
}
}

View File

@ -56,7 +56,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
{ {
$product = Product::factory()->create([ $product = Product::factory()->create([
'type' => ProductType::BOOKING, 'type' => ProductType::BOOKING,
'manage_stock' => true,
]); ]);
$product->increaseStock(10);
ProductPrice::factory()->create([ ProductPrice::factory()->create([
'purchasable_id' => $product->id, 'purchasable_id' => $product->id,
@ -81,7 +83,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
{ {
$product = Product::factory()->create([ $product = Product::factory()->create([
'type' => ProductType::BOOKING, 'type' => ProductType::BOOKING,
'manage_stock' => true,
]); ]);
$product->increaseStock(10);
ProductPrice::factory()->create([ ProductPrice::factory()->create([
'purchasable_id' => $product->id, 'purchasable_id' => $product->id,
@ -108,7 +112,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
{ {
$product = Product::factory()->create([ $product = Product::factory()->create([
'type' => ProductType::BOOKING, 'type' => ProductType::BOOKING,
'manage_stock' => true,
]); ]);
$product->increaseStock(10);
ProductPrice::factory()->create([ ProductPrice::factory()->create([
'purchasable_id' => $product->id, 'purchasable_id' => $product->id,
@ -171,7 +177,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
$singleItem1 = Product::factory()->create([ $singleItem1 = Product::factory()->create([
'name' => 'Parking Spot 1', 'name' => 'Parking Spot 1',
'type' => ProductType::BOOKING, 'type' => ProductType::BOOKING,
'manage_stock' => true,
]); ]);
$singleItem1->increaseStock(10);
ProductPrice::factory()->create([ ProductPrice::factory()->create([
'purchasable_id' => $singleItem1->id, 'purchasable_id' => $singleItem1->id,
@ -184,7 +192,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
$singleItem2 = Product::factory()->create([ $singleItem2 = Product::factory()->create([
'name' => 'Parking Spot 2', 'name' => 'Parking Spot 2',
'type' => ProductType::BOOKING, 'type' => ProductType::BOOKING,
'manage_stock' => true,
]); ]);
$singleItem2->increaseStock(10);
ProductPrice::factory()->create([ ProductPrice::factory()->create([
'purchasable_id' => $singleItem2->id, 'purchasable_id' => $singleItem2->id,
@ -266,7 +276,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
$singleItem1 = Product::factory()->create([ $singleItem1 = Product::factory()->create([
'name' => 'Item 1', 'name' => 'Item 1',
'type' => ProductType::SIMPLE, 'type' => ProductType::SIMPLE,
'manage_stock' => true,
]); ]);
$singleItem1->increaseStock(10);
ProductPrice::factory()->create([ ProductPrice::factory()->create([
'purchasable_id' => $singleItem1->id, 'purchasable_id' => $singleItem1->id,
@ -279,7 +291,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
$singleItem2 = Product::factory()->create([ $singleItem2 = Product::factory()->create([
'name' => 'Item 2', 'name' => 'Item 2',
'type' => ProductType::SIMPLE, 'type' => ProductType::SIMPLE,
'manage_stock' => true,
]); ]);
$singleItem2->increaseStock(10);
ProductPrice::factory()->create([ ProductPrice::factory()->create([
'purchasable_id' => $singleItem2->id, 'purchasable_id' => $singleItem2->id,
@ -317,7 +331,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
$simpleItem = Product::factory()->create([ $simpleItem = Product::factory()->create([
'name' => 'Simple Item', 'name' => 'Simple Item',
'type' => ProductType::SIMPLE, 'type' => ProductType::SIMPLE,
'manage_stock' => true,
]); ]);
$simpleItem->increaseStock(10);
ProductPrice::factory()->create([ ProductPrice::factory()->create([
'purchasable_id' => $simpleItem->id, 'purchasable_id' => $simpleItem->id,
@ -331,7 +347,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
$bookingItem = Product::factory()->create([ $bookingItem = Product::factory()->create([
'name' => 'Booking Item', 'name' => 'Booking Item',
'type' => ProductType::BOOKING, 'type' => ProductType::BOOKING,
'manage_stock' => true,
]); ]);
$bookingItem->increaseStock(10);
ProductPrice::factory()->create([ ProductPrice::factory()->create([
'purchasable_id' => $bookingItem->id, 'purchasable_id' => $bookingItem->id,

View File

@ -0,0 +1,276 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
class PoolAvailabilityMethodsTest extends TestCase
{
protected Product $pool;
protected Product $spot1;
protected Product $spot2;
protected Product $spot3;
protected function setUp(): void
{
parent::setUp();
$this->pool = Product::factory()->create([
'name' => 'Parking Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$this->spot1 = Product::factory()->create([
'name' => 'Spot 1',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$this->spot1->increaseStock(2);
$this->spot2 = Product::factory()->create([
'name' => 'Spot 2',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$this->spot2->increaseStock(3);
$this->spot3 = Product::factory()->create([
'name' => 'Spot 3',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$this->spot3->increaseStock(1);
$this->pool->attachSingleItems([$this->spot1->id, $this->spot2->id, $this->spot3->id]);
}
/** @test */
public function it_gets_pool_availability_calendar()
{
$start = Carbon::now()->addDays(1)->startOfDay();
$end = Carbon::now()->addDays(3)->startOfDay();
$calendar = $this->pool->getPoolAvailabilityCalendar($start, $end);
$this->assertIsArray($calendar);
$this->assertCount(3, $calendar); // 3 days
// Each day should have availability count
foreach ($calendar as $date => $available) {
$this->assertIsString($date);
$this->assertTrue($available === 'unlimited' || is_int($available));
}
}
/** @test */
public function it_shows_correct_availability_per_day()
{
$start = Carbon::now()->addDays(1)->startOfDay();
$end = Carbon::now()->addDays(1)->startOfDay();
$calendar = $this->pool->getPoolAvailabilityCalendar($start, $end);
// Total availability should be 2 + 3 + 1 = 6
$dateStr = $start->format('Y-m-d');
$this->assertEquals(6, $calendar[$dateStr]);
}
/** @test */
public function it_reduces_availability_when_items_are_claimed()
{
$from = Carbon::now()->addDays(1)->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
// Claim 2 items from spot1
$this->spot1->claimStock(2, null, $from, $until);
$calendar = $this->pool->getPoolAvailabilityCalendar($from, $until);
$dateStr = $from->format('Y-m-d');
// Should now be 0 (spot1) + 3 (spot2) + 1 (spot3) = 4
$this->assertEquals(4, $calendar[$dateStr]);
}
/** @test */
public function it_gets_single_items_availability_without_dates()
{
$availability = $this->pool->getSingleItemsAvailability();
$this->assertIsArray($availability);
$this->assertCount(3, $availability);
// Check structure
$this->assertEquals($this->spot1->id, $availability[0]['id']);
$this->assertEquals('Spot 1', $availability[0]['name']);
$this->assertEquals(2, $availability[0]['available']);
$this->assertTrue($availability[0]['manage_stock']);
$this->assertEquals(3, $availability[1]['available']);
$this->assertEquals(1, $availability[2]['available']);
}
/** @test */
public function it_gets_single_items_availability_with_dates()
{
$from = Carbon::now()->addDays(1)->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
// Claim some stock
$this->spot2->claimStock(2, null, $from, $until);
$availability = $this->pool->getSingleItemsAvailability($from, $until);
$this->assertEquals(2, $availability[0]['available']); // Spot 1: still 2
$this->assertEquals(1, $availability[1]['available']); // Spot 2: 3 - 2 claimed = 1
$this->assertEquals(1, $availability[2]['available']); // Spot 3: still 1
}
/** @test */
public function it_shows_unlimited_for_items_without_stock_management()
{
$unlimitedSpot = Product::factory()->create([
'name' => 'Unlimited Spot',
'type' => ProductType::BOOKING,
'manage_stock' => false,
]);
$this->pool->attachSingleItems($unlimitedSpot->id);
$availability = $this->pool->getSingleItemsAvailability();
$unlimited = collect($availability)->firstWhere('id', $unlimitedSpot->id);
$this->assertEquals('unlimited', $unlimited['available']);
$this->assertFalse($unlimited['manage_stock']);
}
/** @test */
public function it_checks_if_pool_is_available_for_period()
{
$from = Carbon::now()->addDays(1)->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
// Pool has 6 total items available
$this->assertTrue($this->pool->isPoolAvailable($from, $until, 6));
$this->assertTrue($this->pool->isPoolAvailable($from, $until, 3));
$this->assertFalse($this->pool->isPoolAvailable($from, $until, 7));
}
/** @test */
public function it_returns_false_when_pool_not_available()
{
$from = Carbon::now()->addDays(1)->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
// Claim all stock
$this->spot1->claimStock(2, null, $from, $until);
$this->spot2->claimStock(3, null, $from, $until);
$this->spot3->claimStock(1, null, $from, $until);
$this->assertFalse($this->pool->isPoolAvailable($from, $until, 1));
}
/** @test */
public function it_gets_available_periods_for_pool()
{
$start = Carbon::now()->addDays(1)->startOfDay();
$end = Carbon::now()->addDays(10)->startOfDay();
// Claim stock for days 3-5
$claimFrom = Carbon::now()->addDays(3)->startOfDay();
$claimUntil = Carbon::now()->addDays(6)->startOfDay();
$this->spot1->claimStock(2, null, $claimFrom, $claimUntil);
$this->spot2->claimStock(3, null, $claimFrom, $claimUntil);
$this->spot3->claimStock(1, null, $claimFrom, $claimUntil);
$periods = $this->pool->getPoolAvailablePeriods($start, $end, 1);
$this->assertIsArray($periods);
// Should have availability before and after the claimed period
$this->assertGreaterThan(0, count($periods));
foreach ($periods as $period) {
$this->assertArrayHasKey('from', $period);
$this->assertArrayHasKey('until', $period);
$this->assertArrayHasKey('min_available', $period);
}
}
/** @test */
public function it_filters_periods_by_minimum_consecutive_days()
{
$start = Carbon::now()->addDays(1)->startOfDay();
$end = Carbon::now()->addDays(10)->startOfDay();
// Create a pattern with short availability gaps
$this->spot1->claimStock(
2,
null,
Carbon::now()->addDays(2)->startOfDay(),
Carbon::now()->addDays(3)->startOfDay()
);
$this->spot1->claimStock(
2,
null,
Carbon::now()->addDays(4)->startOfDay(),
Carbon::now()->addDays(5)->startOfDay()
);
// Get periods with minimum 3 consecutive days
$periods = $this->pool->getPoolAvailablePeriods($start, $end, 1, 3);
foreach ($periods as $period) {
$from = Carbon::parse($period['from']);
$until = Carbon::parse($period['until']);
$days = $from->diffInDays($until) + 1;
// All periods should have at least 3 days
$this->assertGreaterThanOrEqual(3, $days);
}
}
/** @test */
public function it_handles_higher_quantity_requirements_in_available_periods()
{
$start = Carbon::now()->addDays(1)->startOfDay();
$end = Carbon::now()->addDays(5)->startOfDay();
// Pool has 6 items total
// Get periods where at least 5 items are available
$periodsBefore = $this->pool->getPoolAvailablePeriods($start, $end, 5);
$this->assertGreaterThan(0, count($periodsBefore));
// Claim items to reduce availability below 5
$this->spot1->claimStock(
2,
null,
Carbon::now()->addDays(2)->startOfDay(),
Carbon::now()->addDays(4)->startOfDay()
);
// Now get periods where at least 5 items are available
$periodsAfter = $this->pool->getPoolAvailablePeriods($start, $end, 5);
// After claiming 2 items, only 4 items available during days 2-4
// So we should not be able to get periods with 5 items for the full range
// The periods should be different (either fewer or shorter)
$this->assertNotEquals($periodsBefore, $periodsAfter);
}
/** @test */
public function it_throws_exception_for_non_pool_products()
{
$regularProduct = Product::factory()->create([
'type' => ProductType::SIMPLE,
]);
$this->expectException(\Exception::class);
$this->expectExceptionMessage('This method is only for pool products');
$regularProduct->getPoolAvailabilityCalendar(Carbon::now(), Carbon::now()->addDays(1));
}
}

View File

@ -131,7 +131,7 @@ class PoolProductCheckoutTest extends TestCase
]); ]);
$this->expectException(\Exception::class); $this->expectException(\Exception::class);
$this->expectExceptionMessage('requires a timespan'); $this->expectExceptionMessage('is missing required information: from, until');
$cart->checkout(); $cart->checkout();
} }
@ -385,6 +385,8 @@ class PoolProductCheckoutTest extends TestCase
'purchasable_type' => Product::class, 'purchasable_type' => Product::class,
'quantity' => 1, 'quantity' => 1,
'price' => 20.00, 'price' => 20.00,
'from' => $from,
'until' => $until,
'parameters' => [ 'parameters' => [
'from' => $from->toDateTimeString(), 'from' => $from->toDateTimeString(),
'until' => $until->toDateTimeString(), 'until' => $until->toDateTimeString(),

View File

@ -0,0 +1,162 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
class PoolProductRelationsTest extends TestCase
{
/** @test */
public function it_creates_reverse_pool_relation_when_attaching_single_items()
{
// Create pool product
$pool = Product::factory()->create([
'name' => 'Parking Pool',
'type' => ProductType::POOL,
]);
// Create single items
$spot1 = Product::factory()->create([
'name' => 'Parking Spot 1',
'type' => ProductType::BOOKING,
]);
$spot2 = Product::factory()->create([
'name' => 'Parking Spot 2',
'type' => ProductType::BOOKING,
]);
// Use the helper method to attach single items
$pool->attachSingleItems([$spot1->id, $spot2->id]);
// Assert pool has single items
$this->assertEquals(2, $pool->singleProducts()->count());
$this->assertTrue($pool->singleProducts->contains($spot1));
$this->assertTrue($pool->singleProducts->contains($spot2));
// Assert single items have reverse pool relation
$this->assertEquals(1, $spot1->poolProducts()->count());
$this->assertEquals(1, $spot2->poolProducts()->count());
$this->assertTrue($spot1->poolProducts->contains($pool));
$this->assertTrue($spot2->poolProducts->contains($pool));
// Verify pivot type
$spot1Pivot = $spot1->productRelations()->where('related_product_id', $pool->id)->first();
$this->assertEquals(ProductRelationType::POOL->value, $spot1Pivot->pivot->type);
}
/** @test */
public function it_can_attach_single_item_using_id()
{
$pool = Product::factory()->create([
'name' => 'Parking Pool',
'type' => ProductType::POOL,
]);
$spot = Product::factory()->create([
'name' => 'Parking Spot',
'type' => ProductType::BOOKING,
]);
// Attach single ID (not array)
$pool->attachSingleItems($spot->id);
$this->assertEquals(1, $pool->singleProducts()->count());
$this->assertTrue($pool->singleProducts->contains($spot));
$this->assertEquals(1, $spot->poolProducts()->count());
$this->assertTrue($spot->poolProducts->contains($pool));
}
/** @test */
public function it_throws_exception_when_non_pool_tries_to_attach_single_items()
{
$regularProduct = Product::factory()->create([
'type' => ProductType::SIMPLE,
]);
$spot = Product::factory()->create([
'type' => ProductType::BOOKING,
]);
$this->expectException(\Exception::class);
$this->expectExceptionMessage('This method is only for pool products');
$regularProduct->attachSingleItems($spot->id);
}
/** @test */
public function single_item_can_belong_to_multiple_pools()
{
// Create two pools
$pool1 = Product::factory()->create([
'name' => 'Zone A Pool',
'type' => ProductType::POOL,
]);
$pool2 = Product::factory()->create([
'name' => 'Zone B Pool',
'type' => ProductType::POOL,
]);
// Create single item
$spot = Product::factory()->create([
'name' => 'Flexible Spot',
'type' => ProductType::BOOKING,
]);
// Attach to both pools
$pool1->attachSingleItems($spot->id);
$pool2->attachSingleItems($spot->id);
// Assert spot belongs to both pools
$this->assertEquals(2, $spot->poolProducts()->count());
$this->assertTrue($spot->poolProducts->contains($pool1));
$this->assertTrue($spot->poolProducts->contains($pool2));
// Assert each pool has the spot
$this->assertTrue($pool1->singleProducts->contains($spot));
$this->assertTrue($pool2->singleProducts->contains($spot));
}
/** @test */
public function it_can_get_all_pools_for_a_single_item()
{
$pool1 = Product::factory()->create(['type' => ProductType::POOL, 'name' => 'Pool 1']);
$pool2 = Product::factory()->create(['type' => ProductType::POOL, 'name' => 'Pool 2']);
$pool3 = Product::factory()->create(['type' => ProductType::POOL, 'name' => 'Pool 3']);
$spot = Product::factory()->create(['type' => ProductType::BOOKING, 'name' => 'Spot']);
$pool1->attachSingleItems($spot->id);
$pool2->attachSingleItems($spot->id);
$pool3->attachSingleItems($spot->id);
$pools = $spot->poolProducts;
$this->assertCount(3, $pools);
$this->assertTrue($pools->contains('name', 'Pool 1'));
$this->assertTrue($pools->contains('name', 'Pool 2'));
$this->assertTrue($pools->contains('name', 'Pool 3'));
}
/** @test */
public function legacy_manual_attach_still_works()
{
// Test that old way of attaching still works (without reverse relation)
$pool = Product::factory()->create(['type' => ProductType::POOL]);
$spot = Product::factory()->create(['type' => ProductType::BOOKING]);
// Old way using productRelations()->attach() directly
$pool->productRelations()->attach($spot->id, ['type' => ProductRelationType::SINGLE->value]);
// Pool should have the single item
$this->assertEquals(1, $pool->singleProducts()->count());
$this->assertTrue($pool->singleProducts->contains($spot));
// But single item won't have reverse relation (unless manually added)
// This is expected behavior for legacy code
$this->assertEquals(0, $spot->poolProducts()->count());
}
}

View File

@ -420,7 +420,9 @@ class PoolProductTest extends TestCase
// Should detect booking items exist // Should detect booking items exist
$this->assertTrue($mixedPool->hasBookingSingleItems()); $this->assertTrue($mixedPool->hasBookingSingleItems());
$this->assertEquals(2, $mixedPool->getPoolMaxQuantity()); // Pool max quantity should be the stock of managed items (1 from booking item)
// Unlimited items (simple item) don't contribute to the count
$this->assertEquals(1, $mixedPool->getPoolMaxQuantity());
} }
/** @test */ /** @test */
@ -505,8 +507,8 @@ class PoolProductTest extends TestCase
'type' => ProductRelationType::SINGLE->value, 'type' => ProductRelationType::SINGLE->value,
]); ]);
// Should still count as 1 item (not based on stock quantity) // Should count the stock quantity (2), not just the number of items (1)
$this->assertEquals(1, $customPool->getPoolMaxQuantity($from, $until)); $this->assertEquals(2, $customPool->getPoolMaxQuantity($from, $until));
} }
/** @test */ /** @test */

View File

@ -0,0 +1,142 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Workbench\App\Models\User;
class PoolSeparateCartItemsTest extends TestCase
{
protected User $user;
protected Cart $cart;
protected Product $pool;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
// Create pool with single items
$this->pool = Product::factory()->create([
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$spot1 = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$spot1->increaseStock(10);
$spot2 = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$spot2->increaseStock(10);
ProductPrice::factory()->create([
'purchasable_id' => $spot1->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
ProductPrice::factory()->create([
'purchasable_id' => $spot2->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$this->pool->attachSingleItems([$spot1->id, $spot2->id]);
}
/** @test */
public function it_creates_separate_cart_items_for_different_dates()
{
$from1 = Carbon::now()->addDays(1)->startOfDay();
$until1 = Carbon::now()->addDays(3)->startOfDay();
$from2 = Carbon::now()->addDays(5)->startOfDay();
$until2 = Carbon::now()->addDays(7)->startOfDay();
$item1 = $this->cart->addToCart($this->pool, 1, [], $from1, $until1);
$item2 = $this->cart->addToCart($this->pool, 1, [], $from2, $until2);
// Should create two separate cart items
$this->assertNotEquals($item1->id, $item2->id);
$this->assertEquals(2, $this->cart->items()->count());
}
/** @test */
public function it_merges_cart_items_with_same_dates_and_price()
{
$from = Carbon::now()->addDays(1)->startOfDay();
$until = Carbon::now()->addDays(3)->startOfDay();
$item1 = $this->cart->addToCart($this->pool, 1, [], $from, $until);
$item2 = $this->cart->addToCart($this->pool, 1, [], $from, $until);
// Should merge into one cart item
$this->assertEquals($item1->id, $item2->id);
$this->assertEquals(1, $this->cart->items()->count());
$this->assertEquals(2, $item2->quantity);
}
/** @test */
public function it_creates_separate_cart_items_when_price_changes()
{
$from = Carbon::now()->addDays(1)->startOfDay();
$until = Carbon::now()->addDays(3)->startOfDay();
// Add first item
$item1 = $this->cart->addToCart($this->pool, 1, [], $from, $until);
$price1 = $item1->price;
// Change the price on one of the single items
$singleItem = $this->pool->singleProducts->first();
$priceRecord = ProductPrice::where('purchasable_id', $singleItem->id)->first();
$priceRecord->update(['unit_amount' => 8000]); // Changed from 5000 to 8000
// Clear cache if any
$this->pool->refresh();
$singleItem->refresh();
// Add second item with same dates but different price
$item2 = $this->cart->addToCart($this->pool, 1, [], $from, $until);
// Should create separate cart items because price is different
$this->assertNotEquals($item1->id, $item2->id);
$this->assertEquals(2, $this->cart->items()->count());
$this->assertNotEquals($price1, $item2->price);
}
/** @test */
public function it_creates_separate_cart_items_for_different_date_lengths()
{
$from = Carbon::now()->addDays(1)->startOfDay();
$until1 = Carbon::now()->addDays(3)->startOfDay(); // 2 days
$until2 = Carbon::now()->addDays(5)->startOfDay(); // 4 days
$item1 = $this->cart->addToCart($this->pool, 1, [], $from, $until1);
$item2 = $this->cart->addToCart($this->pool, 1, [], $from, $until2);
// Different date ranges mean different prices, so separate items
$this->assertNotEquals($item1->id, $item2->id);
$this->assertEquals(2, $this->cart->items()->count());
$this->assertNotEquals($item1->price, $item2->price);
}
}

View File

@ -12,6 +12,8 @@ abstract class TestCase extends Orchestra
{ {
parent::setUp(); parent::setUp();
ini_set('memory_limit', '256M');
Factory::guessFactoryNamesUsing( Factory::guessFactoryNamesUsing(
fn(string $modelName) => match (true) { fn(string $modelName) => match (true) {
str_starts_with($modelName, 'Workbench\\App\\') => 'Workbench\\Database\\Factories\\' . class_basename($modelName) . 'Factory', str_starts_with($modelName, 'Workbench\\App\\') => 'Workbench\\Database\\Factories\\' . class_basename($modelName) . 'Factory',