From 3045f7230484a577010c8a0d6358569e7ad02673 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Mon, 15 Dec 2025 14:10:59 +0100 Subject: [PATCH] BFI cart, A stripe logic, checkout & tests, A tests, RA pool product --- config/shop.php | 1 + docs/04-stripe-checkout.md | 279 ++++++++ routes/api.php | 25 + src/Enums/ProductRelationType.php | 2 + src/Enums/PurchaseStatus.php | 2 + .../Controllers/StripeCheckoutController.php | 155 +++++ .../Controllers/StripeWebhookController.php | 270 ++++++++ src/Models/Cart.php | 207 +++++- src/Models/CartItem.php | 87 +++ src/Models/Product.php | 342 +-------- src/Services/StripeSyncService.php | 197 ++++++ src/Traits/HasProductRelations.php | 5 + src/Traits/MayBePoolProduct.php | 655 ++++++++++++++++++ .../Feature/CartAddToCartPoolPricingTest.php | 199 ++++++ tests/Feature/CartItemDateManagementTest.php | 262 +++++++ .../CartItemRequiredAdjustmentsTest.php | 18 + tests/Feature/PoolAvailabilityMethodsTest.php | 276 ++++++++ tests/Feature/PoolProductCheckoutTest.php | 4 +- tests/Feature/PoolProductRelationsTest.php | 162 +++++ tests/Feature/PoolProductTest.php | 8 +- tests/Feature/PoolSeparateCartItemsTest.php | 142 ++++ tests/TestCase.php | 2 + 22 files changed, 2955 insertions(+), 345 deletions(-) create mode 100644 docs/04-stripe-checkout.md create mode 100644 src/Http/Controllers/StripeCheckoutController.php create mode 100644 src/Http/Controllers/StripeWebhookController.php create mode 100644 src/Services/StripeSyncService.php create mode 100644 src/Traits/MayBePoolProduct.php create mode 100644 tests/Feature/CartItemDateManagementTest.php create mode 100644 tests/Feature/PoolAvailabilityMethodsTest.php create mode 100644 tests/Feature/PoolProductRelationsTest.php create mode 100644 tests/Feature/PoolSeparateCartItemsTest.php diff --git a/config/shop.php b/config/shop.php index 7fc1ef9..38d4af5 100644 --- a/config/shop.php +++ b/config/shop.php @@ -61,6 +61,7 @@ return [ 'stripe' => [ 'enabled' => env('SHOP_STRIPE_ENABLED', false), 'sync_prices' => true, + 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), ], // Cache configuration diff --git a/docs/04-stripe-checkout.md b/docs/04-stripe-checkout.md new file mode 100644 index 0000000..128d8b1 --- /dev/null +++ b/docs/04-stripe-checkout.md @@ -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 diff --git a/routes/api.php b/routes/api.php index 7487431..7ccfec6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,8 @@ use Blax\Shop\Http\Controllers\Api\CategoryController; use Blax\Shop\Http\Controllers\Api\ProductController; +use Blax\Shop\Http\Controllers\StripeCheckoutController; +use Blax\Shop\Http\Controllers\StripeWebhookController; use Illuminate\Support\Facades\Route; $config = config('shop.routes'); @@ -31,3 +33,26 @@ Route::prefix($config['prefix']) Route::get('products/{slug}', [ProductController::class, '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'); + }); +} diff --git a/src/Enums/ProductRelationType.php b/src/Enums/ProductRelationType.php index 868dd89..663a33b 100644 --- a/src/Enums/ProductRelationType.php +++ b/src/Enums/ProductRelationType.php @@ -12,6 +12,7 @@ enum ProductRelationType: string case ADD_ON = 'add-on'; case BUNDLE = 'bundle'; case SINGLE = 'single'; + case POOL = 'pool'; public function label(): string @@ -25,6 +26,7 @@ enum ProductRelationType: string self::ADD_ON => 'Add-on', self::BUNDLE => 'Bundle', self::SINGLE => 'Single', + self::POOL => 'Pool', }; } } diff --git a/src/Enums/PurchaseStatus.php b/src/Enums/PurchaseStatus.php index 4a571a3..c48e291 100644 --- a/src/Enums/PurchaseStatus.php +++ b/src/Enums/PurchaseStatus.php @@ -9,6 +9,7 @@ enum PurchaseStatus: string case COMPLETED = 'completed'; case REFUNDED = 'refunded'; case CART = 'cart'; + case FAILED = 'failed'; public function label(): string { @@ -18,6 +19,7 @@ enum PurchaseStatus: string self::COMPLETED => 'Completed', self::REFUNDED => 'Refunded', self::CART => 'Cart', + self::FAILED => 'Failed', }; } } diff --git a/src/Http/Controllers/StripeCheckoutController.php b/src/Http/Controllers/StripeCheckoutController.php new file mode 100644 index 0000000..d8ca3e1 --- /dev/null +++ b/src/Http/Controllers/StripeCheckoutController.php @@ -0,0 +1,155 @@ +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); + } + } +} diff --git a/src/Http/Controllers/StripeWebhookController.php b/src/Http/Controllers/StripeWebhookController.php new file mode 100644 index 0000000..38f27be --- /dev/null +++ b/src/Http/Controllers/StripeWebhookController.php @@ -0,0 +1,270 @@ +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); + } + } +} diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 30745e7..35f64c1 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -6,6 +6,7 @@ use Blax\Shop\Contracts\Cartable; use Blax\Shop\Enums\CartStatus; use Blax\Shop\Enums\ProductType; use Blax\Workkit\Traits\HasExpiration; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -200,6 +201,14 @@ class Cart extends Model 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 if ($cartable instanceof Product) { // Validate pricing before adding to cart @@ -222,7 +231,8 @@ class Cart extends Model // Check pool product availability if dates are provided if ($cartable->isPool()) { $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( "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) { // 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."); + } 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() ->where('purchasable_id', $cartable->getKey()) ->where('purchasable_type', get_class($cartable)) ->get() - ->first(function ($item) use ($parameters, $from, $until) { + ->first(function ($item) use ($parameters, $from, $until, $cartable) { $existingParams = is_array($item->parameters) ? $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) @@ -347,7 +392,12 @@ class Cart extends Model return $item ?? true; } - public function checkout(): static + /** + * Validate cart for checkout without converting it + * + * @throws \Exception + */ + public function validateForCheckout(): void { $items = $this->items() ->with('purchasable') @@ -357,6 +407,27 @@ class Cart extends Model 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 foreach ($items as $item) { $product = $item->purchasable; @@ -438,4 +509,130 @@ class Cart extends Model 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; + } + } } diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index fb03764..03b2175 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -168,4 +168,91 @@ class CartItem extends Model 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(); + } } diff --git a/src/Models/Product.php b/src/Models/Product.php index 5ed0a4f..b69f39d 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -9,6 +9,7 @@ use Blax\Shop\Events\ProductUpdated; use Blax\Shop\Contracts\Purchasable; use Blax\Shop\Enums\ProductStatus; use Blax\Shop\Enums\ProductType; +use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockType; use Blax\Shop\Exceptions\HasNoDefaultPriceException; @@ -19,6 +20,7 @@ use Blax\Shop\Traits\HasCategories; use Blax\Shop\Traits\HasPrices; use Blax\Shop\Traits\HasProductRelations; use Blax\Shop\Traits\HasStocks; +use Blax\Shop\Traits\MayBePoolProduct; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -28,7 +30,7 @@ use Illuminate\Support\Facades\Cache; 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 = [ 'slug', @@ -316,148 +318,6 @@ class Product extends Model implements Purchasable, Cartable return $this->type === ProductType::BOOKING; } - /** - * Check if this is a pool product - */ - public function isPool(): bool - { - return $this->type === ProductType::POOL; - } - - /** - * Get the maximum available quantity for a pool product based on single items - */ - public function getPoolMaxQuantity(\DateTimeInterface $from = null, \DateTimeInterface $until = null): int - { - if (!$this->isPool()) { - return $this->getAvailableStock(); - } - - $singleItems = $this->singleProducts; - - if ($singleItems->isEmpty()) { - return 0; - } - - // If no dates provided, return the count of single items - if (!$from || !$until) { - return $singleItems->count(); - } - - // Check availability for each single item during the timespan - $availableCount = 0; - foreach ($singleItems as $item) { - if ($item->isAvailableForBooking($from, $until, 1)) { - $availableCount++; - } - } - - return $availableCount; - } - - /** - * Claim stock for a pool product - * This will claim stock from the available single items - * - * @param int $quantity Number of pool items to claim - * @param mixed $reference Reference model - * @param \DateTimeInterface|null $from Start date - * @param \DateTimeInterface|null $until End date - * @param string|null $note Optional note - * @return array Array of claimed single item products - * @throws \Exception - */ - public function claimPoolStock( - int $quantity, - $reference = null, - ?\DateTimeInterface $from = null, - ?\DateTimeInterface $until = null, - ?string $note = null - ): array { - if (!$this->isPool()) { - throw new \Exception('This method is only for pool products'); - } - - $singleItems = $this->singleProducts; - - if ($singleItems->isEmpty()) { - throw new \Exception('Pool product has no single items to claim'); - } - - // Get available single items for the period - $availableItems = []; - foreach ($singleItems as $item) { - if ($item->isAvailableForBooking($from, $until, 1)) { - $availableItems[] = $item; - } - - if (count($availableItems) >= $quantity) { - break; - } - } - - if (count($availableItems) < $quantity) { - throw new \Exception("Only " . count($availableItems) . " items available, but {$quantity} requested"); - } - - // Claim stock from each selected single item - $claimedItems = []; - foreach (array_slice($availableItems, 0, $quantity) as $item) { - $item->claimStock(1, $reference, $from, $until, $note); - $claimedItems[] = $item; - } - - return $claimedItems; - } - - /** - * Release pool stock claims - * - * @param mixed $reference Reference model used when claiming - * @return int Number of claims released - */ - public function releasePoolStock($reference): int - { - if (!$this->isPool()) { - throw new \Exception('This method is only for pool products'); - } - - $singleItems = $this->singleProducts; - $released = 0; - - foreach ($singleItems as $item) { - $referenceType = is_object($reference) ? get_class($reference) : null; - $referenceId = is_object($reference) ? $reference->id : null; - - // Find and delete claims for this reference - $claims = $item->stocks() - ->where('type', StockType::CLAIMED->value) - ->where('status', StockStatus::PENDING->value) - ->where('reference_type', $referenceType) - ->where('reference_id', $referenceId) - ->get(); - - foreach ($claims as $claim) { - $claim->release(); - $released++; - } - } - - return $released; - } - - /** - * Check if any single item in pool is a booking product - */ - public function hasBookingSingleItems(): bool - { - if (!$this->isPool()) { - return false; - } - - return $this->singleProducts()->where('products.type', ProductType::BOOKING->value)->exists(); - } - /** * Check stock availability for a booking period */ @@ -511,205 +371,15 @@ class Product extends Model implements Purchasable, Cartable */ public function getCurrentPrice(bool|null $sales_price = null): ?float { - // If this is a pool product and it has no direct price, inherit from single items - if ($this->isPool() && !$this->hasPrice()) { - return $this->getInheritedPoolPrice($sales_price); - } - - // If pool has a direct price, use it - if ($this->isPool() && $this->hasPrice()) { - return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale()); + // If this is a pool product, use the trait method + if ($this->isPool()) { + return $this->getPoolCurrentPrice($sales_price); } // For non-pool products, use the trait's default behavior return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale()); } - /** - * Get inherited price from single items based on pricing strategy - */ - protected function getInheritedPoolPrice(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 * diff --git a/src/Services/StripeSyncService.php b/src/Services/StripeSyncService.php new file mode 100644 index 0000000..2ae777d --- /dev/null +++ b/src/Services/StripeSyncService.php @@ -0,0 +1,197 @@ +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, + ]; + } +} diff --git a/src/Traits/HasProductRelations.php b/src/Traits/HasProductRelations.php index 11ce2ee..429be3b 100644 --- a/src/Traits/HasProductRelations.php +++ b/src/Traits/HasProductRelations.php @@ -57,6 +57,11 @@ trait HasProductRelations return $this->relationsByType(ProductRelationType::SINGLE); } + public function poolProducts(): BelongsToMany + { + return $this->relationsByType(ProductRelationType::POOL); + } + public function relationsByType(ProductRelationType|string $type): BelongsToMany { $typeValue = $type instanceof ProductRelationType ? $type->value : $type; diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php new file mode 100644 index 0000000..b619ab4 --- /dev/null +++ b/src/Traits/MayBePoolProduct.php @@ -0,0 +1,655 @@ +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; + } +} diff --git a/tests/Feature/CartAddToCartPoolPricingTest.php b/tests/Feature/CartAddToCartPoolPricingTest.php index f0af7c5..6dbdb0a 100644 --- a/tests/Feature/CartAddToCartPoolPricingTest.php +++ b/tests/Feature/CartAddToCartPoolPricingTest.php @@ -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(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); + } } diff --git a/tests/Feature/CartItemDateManagementTest.php b/tests/Feature/CartItemDateManagementTest.php new file mode 100644 index 0000000..449cda9 --- /dev/null +++ b/tests/Feature/CartItemDateManagementTest.php @@ -0,0 +1,262 @@ +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); + } +} diff --git a/tests/Feature/CartItemRequiredAdjustmentsTest.php b/tests/Feature/CartItemRequiredAdjustmentsTest.php index ccc64dd..b36668b 100644 --- a/tests/Feature/CartItemRequiredAdjustmentsTest.php +++ b/tests/Feature/CartItemRequiredAdjustmentsTest.php @@ -56,7 +56,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, + 'manage_stock' => true, ]); + $product->increaseStock(10); ProductPrice::factory()->create([ 'purchasable_id' => $product->id, @@ -81,7 +83,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, + 'manage_stock' => true, ]); + $product->increaseStock(10); ProductPrice::factory()->create([ 'purchasable_id' => $product->id, @@ -108,7 +112,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, + 'manage_stock' => true, ]); + $product->increaseStock(10); ProductPrice::factory()->create([ 'purchasable_id' => $product->id, @@ -171,7 +177,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase $singleItem1 = Product::factory()->create([ 'name' => 'Parking Spot 1', 'type' => ProductType::BOOKING, + 'manage_stock' => true, ]); + $singleItem1->increaseStock(10); ProductPrice::factory()->create([ 'purchasable_id' => $singleItem1->id, @@ -184,7 +192,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase $singleItem2 = Product::factory()->create([ 'name' => 'Parking Spot 2', 'type' => ProductType::BOOKING, + 'manage_stock' => true, ]); + $singleItem2->increaseStock(10); ProductPrice::factory()->create([ 'purchasable_id' => $singleItem2->id, @@ -266,7 +276,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase $singleItem1 = Product::factory()->create([ 'name' => 'Item 1', 'type' => ProductType::SIMPLE, + 'manage_stock' => true, ]); + $singleItem1->increaseStock(10); ProductPrice::factory()->create([ 'purchasable_id' => $singleItem1->id, @@ -279,7 +291,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase $singleItem2 = Product::factory()->create([ 'name' => 'Item 2', 'type' => ProductType::SIMPLE, + 'manage_stock' => true, ]); + $singleItem2->increaseStock(10); ProductPrice::factory()->create([ 'purchasable_id' => $singleItem2->id, @@ -317,7 +331,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase $simpleItem = Product::factory()->create([ 'name' => 'Simple Item', 'type' => ProductType::SIMPLE, + 'manage_stock' => true, ]); + $simpleItem->increaseStock(10); ProductPrice::factory()->create([ 'purchasable_id' => $simpleItem->id, @@ -331,7 +347,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase $bookingItem = Product::factory()->create([ 'name' => 'Booking Item', 'type' => ProductType::BOOKING, + 'manage_stock' => true, ]); + $bookingItem->increaseStock(10); ProductPrice::factory()->create([ 'purchasable_id' => $bookingItem->id, diff --git a/tests/Feature/PoolAvailabilityMethodsTest.php b/tests/Feature/PoolAvailabilityMethodsTest.php new file mode 100644 index 0000000..ce9e3ca --- /dev/null +++ b/tests/Feature/PoolAvailabilityMethodsTest.php @@ -0,0 +1,276 @@ +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)); + } +} diff --git a/tests/Feature/PoolProductCheckoutTest.php b/tests/Feature/PoolProductCheckoutTest.php index 7e1a82e..b3f87cb 100644 --- a/tests/Feature/PoolProductCheckoutTest.php +++ b/tests/Feature/PoolProductCheckoutTest.php @@ -131,7 +131,7 @@ class PoolProductCheckoutTest extends TestCase ]); $this->expectException(\Exception::class); - $this->expectExceptionMessage('requires a timespan'); + $this->expectExceptionMessage('is missing required information: from, until'); $cart->checkout(); } @@ -385,6 +385,8 @@ class PoolProductCheckoutTest extends TestCase 'purchasable_type' => Product::class, 'quantity' => 1, 'price' => 20.00, + 'from' => $from, + 'until' => $until, 'parameters' => [ 'from' => $from->toDateTimeString(), 'until' => $until->toDateTimeString(), diff --git a/tests/Feature/PoolProductRelationsTest.php b/tests/Feature/PoolProductRelationsTest.php new file mode 100644 index 0000000..04d5eb9 --- /dev/null +++ b/tests/Feature/PoolProductRelationsTest.php @@ -0,0 +1,162 @@ +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()); + } +} diff --git a/tests/Feature/PoolProductTest.php b/tests/Feature/PoolProductTest.php index a170267..4c8c8c7 100644 --- a/tests/Feature/PoolProductTest.php +++ b/tests/Feature/PoolProductTest.php @@ -420,7 +420,9 @@ class PoolProductTest extends TestCase // Should detect booking items exist $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 */ @@ -505,8 +507,8 @@ class PoolProductTest extends TestCase 'type' => ProductRelationType::SINGLE->value, ]); - // Should still count as 1 item (not based on stock quantity) - $this->assertEquals(1, $customPool->getPoolMaxQuantity($from, $until)); + // Should count the stock quantity (2), not just the number of items (1) + $this->assertEquals(2, $customPool->getPoolMaxQuantity($from, $until)); } /** @test */ diff --git a/tests/Feature/PoolSeparateCartItemsTest.php b/tests/Feature/PoolSeparateCartItemsTest.php new file mode 100644 index 0000000..abafcaa --- /dev/null +++ b/tests/Feature/PoolSeparateCartItemsTest.php @@ -0,0 +1,142 @@ +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); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index f119060..d9268bc 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -12,6 +12,8 @@ abstract class TestCase extends Orchestra { parent::setUp(); + ini_set('memory_limit', '256M'); + Factory::guessFactoryNamesUsing( fn(string $modelName) => match (true) { str_starts_with($modelName, 'Workbench\\App\\') => 'Workbench\\Database\\Factories\\' . class_basename($modelName) . 'Factory',