diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 2039747..30745e7 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -75,6 +75,46 @@ class Cart extends Model return $this->items->sum('quantity'); } + /** + * Get all cart items that require adjustments before checkout. + * + * This method checks all cart items and returns a collection of items + * that need additional information (like booking dates) before checkout. + * + * Example usage: + * ```php + * $incompleteItems = $cart->getItemsRequiringAdjustments(); + * + * if ($incompleteItems->isNotEmpty()) { + * foreach ($incompleteItems as $item) { + * $adjustments = $item->requiredAdjustments(); + * // Display what's needed: ['from' => 'datetime', 'until' => 'datetime'] + * } + * } + * ``` + * + * @return \Illuminate\Support\Collection Collection of CartItem models requiring adjustments + */ + public function getItemsRequiringAdjustments() + { + return $this->items->filter(function ($item) { + return !empty($item->requiredAdjustments()); + }); + } + + /** + * Check if cart is ready for checkout. + * + * Returns true if all cart items have all required information set. + * For booking products and pools with booking items, this means dates must be set. + * + * @return bool True if ready for checkout, false if any items need adjustments + */ + public function isReadyForCheckout(): bool + { + return $this->getItemsRequiringAdjustments()->isEmpty(); + } + public function getUnpaidAmount(): float { $paidAmount = $this->purchases() @@ -143,25 +183,63 @@ class Cart extends Model * @param Model&Cartable $cartable The item to add to cart * @param int $quantity The quantity to add * @param array $parameters Additional parameters for the cart item + * @param \DateTimeInterface|null $from Optional start date for bookings + * @param \DateTimeInterface|null $until Optional end date for bookings * @return CartItem * @throws \Exception If the item doesn't implement Cartable interface */ public function addToCart( Model $cartable, int $quantity = 1, - array $parameters = [] + array $parameters = [], + \DateTimeInterface $from = null, + \DateTimeInterface $until = null ): CartItem { // $cartable must implement Cartable if (! $cartable instanceof Cartable) { throw new \Exception("Item must implement the Cartable interface."); } - // Check if item already exists in cart with same parameters + // Validate Product-specific requirements + if ($cartable instanceof Product) { + // Validate pricing before adding to cart + $cartable->validatePricing(throwExceptions: true); + + // Validate dates if both are provided (optional for cart, required at checkout) + if ($from && $until) { + // Validate from is before until + if ($from >= $until) { + throw new \Exception("The 'from' date must be before the 'until' date. Got from: {$from->format('Y-m-d H:i:s')}, until: {$until->format('Y-m-d H:i:s')}"); + } + + // Check booking product availability if dates are provided + if ($cartable->isBooking() && !$cartable->isAvailableForBooking($from, $until, $quantity)) { + throw new \Blax\Shop\Exceptions\NotEnoughStockException( + "Product '{$cartable->name}' is not available for the requested period ({$from->format('Y-m-d')} to {$until->format('Y-m-d')})." + ); + } + + // Check pool product availability if dates are provided + if ($cartable->isPool()) { + $maxQuantity = $cartable->getPoolMaxQuantity($from, $until); + if ($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}" + ); + } + } + } 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."); + } + } + + // Check if item already exists in cart with same parameters and dates $existingItem = $this->items() ->where('purchasable_id', $cartable->getKey()) ->where('purchasable_type', get_class($cartable)) ->get() - ->first(function ($item) use ($parameters) { + ->first(function ($item) use ($parameters, $from, $until) { $existingParams = is_array($item->parameters) ? $item->parameters : (array) $item->parameters; @@ -170,15 +248,49 @@ class Cart extends Model ksort($existingParams); ksort($parameters); - return $existingParams === $parameters; + // Check parameters match + $paramsMatch = $existingParams === $parameters; + + // Check dates match (important for bookings) + $datesMatch = true; + if ($from || $until) { + $datesMatch = ( + ($item->from?->format('Y-m-d H:i:s') === $from?->format('Y-m-d H:i:s')) && + ($item->until?->format('Y-m-d H:i:s') === $until?->format('Y-m-d H:i:s')) + ); + } + + return $paramsMatch && $datesMatch; }); + // Calculate price per day (base price) + $pricePerDay = $cartable->getCurrentPrice(); + $regularPricePerDay = $cartable->getCurrentPrice(false) ?? $pricePerDay; + + // Ensure prices are not null + if ($pricePerDay === null) { + throw new \Exception("Product '{$cartable->name}' has no valid price."); + } + + // Calculate days if booking dates provided + $days = 1; + if ($from && $until) { + $days = max(1, $from->diff($until)->days); + } + + // Calculate price per unit for the entire period + $pricePerUnit = $pricePerDay * $days; + $regularPricePerUnit = $regularPricePerDay * $days; + + // Calculate total price + $totalPrice = $pricePerUnit * $quantity; + if ($existingItem) { // Update quantity and subtotal $newQuantity = $existingItem->quantity + $quantity; $existingItem->update([ 'quantity' => $newQuantity, - 'subtotal' => ($cartable->getCurrentPrice()) * $newQuantity, + 'subtotal' => $pricePerUnit * $newQuantity, ]); return $existingItem->fresh(); @@ -189,10 +301,12 @@ class Cart extends Model 'purchasable_id' => $cartable->getKey(), 'purchasable_type' => get_class($cartable), 'quantity' => $quantity, - 'price' => $cartable->getCurrentPrice(), - 'regular_price' => $cartable->getCurrentPrice(false) ?? $cartable->unit_amount, - 'subtotal' => ($cartable->getCurrentPrice()) * $quantity, + 'price' => $pricePerUnit, // Price per unit for the period + 'regular_price' => $regularPricePerUnit, + 'subtotal' => $totalPrice, // Total for all units 'parameters' => $parameters, + 'from' => $from, + 'until' => $until, ]); return $cartItem->fresh(); diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index d39b1b5..fb03764 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -103,4 +103,69 @@ class CartItem extends Model { return $query->where('product_id', $productId); } + + /** + * Get required adjustments for this cart item before checkout. + * + * Returns an array of fields that need to be set, with suggested field names. + * For booking products and pools with booking items, dates are required. + * + * This method is useful for: + * - Validating cart items before checkout + * - Displaying missing information to users + * - Checking if a cart item needs additional user input + * + * Example usage: + * ```php + * // Check if cart item needs adjustments + * $adjustments = $cartItem->requiredAdjustments(); + * + * if (!empty($adjustments)) { + * // Item needs dates before checkout + * // $adjustments = ['from' => 'datetime', 'until' => 'datetime'] + * echo "Please select booking dates"; + * } + * + * // Check all cart items before checkout + * foreach ($cart->items as $item) { + * $required = $item->requiredAdjustments(); + * if (!empty($required)) { + * // Handle missing information + * } + * } + * ``` + * + * @return array Array of required field adjustments, e.g., ['from' => 'datetime', 'until' => 'datetime'] + */ + public function requiredAdjustments(): array + { + $adjustments = []; + + // Only check if purchasable is a Product + if ($this->purchasable_type !== config('shop.models.product', Product::class)) { + return $adjustments; + } + + $product = $this->purchasable; + + if (!$product) { + return $adjustments; + } + + // Check if dates are required (for booking products or pools with booking items) + $requiresDates = $product->isBooking() || + ($product->isPool() && $product->hasBookingSingleItems()); + + if ($requiresDates) { + if (is_null($this->from)) { + $adjustments['from'] = 'datetime'; + } + + if (is_null($this->until)) { + $adjustments['until'] = 'datetime'; + } + } + + return $adjustments; + } } diff --git a/src/Models/Product.php b/src/Models/Product.php index 51b83d1..5ed0a4f 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -513,7 +513,7 @@ class Product extends Model implements Purchasable, Cartable { // If this is a pool product and it has no direct price, inherit from single items if ($this->isPool() && !$this->hasPrice()) { - return $this->getInheritedPoolPrice(); + return $this->getInheritedPoolPrice($sales_price); } // If pool has a direct price, use it @@ -528,7 +528,7 @@ class Product extends Model implements Purchasable, Cartable /** * Get inherited price from single items based on pricing strategy */ - protected function getInheritedPoolPrice(): ?float + protected function getInheritedPoolPrice(bool|null $sales_price = null): ?float { if (!$this->isPool()) { return null; @@ -542,8 +542,8 @@ class Product extends Model implements Purchasable, Cartable return null; } - $prices = $singleItems->map(function ($item) { - return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale()); + $prices = $singleItems->map(function ($item) use ($sales_price) { + return $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale()); })->filter()->values(); if ($prices->isEmpty()) { diff --git a/tests/Feature/CartAddToCartPoolPricingTest.php b/tests/Feature/CartAddToCartPoolPricingTest.php new file mode 100644 index 0000000..f0af7c5 --- /dev/null +++ b/tests/Feature/CartAddToCartPoolPricingTest.php @@ -0,0 +1,796 @@ +user = User::factory()->create(); + $this->cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + // Create pool product + $this->poolProduct = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create single items + $this->singleItem1 = Product::factory()->create([ + 'name' => 'Parking Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->singleItem1->increaseStock(1); + + $this->singleItem2 = Product::factory()->create([ + 'name' => 'Parking Spot 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->singleItem2->increaseStock(1); + + // Link single items to pool + $this->poolProduct->productRelations()->attach($this->singleItem1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + $this->poolProduct->productRelations()->attach($this->singleItem2->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + } + + /** @test */ + public function it_adds_pool_with_direct_price_to_cart_without_dates() + { + // Set direct price on pool + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, // 30.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + $cartItem = $this->cart->addToCart($this->poolProduct, 1); + + $this->assertNotNull($cartItem); + $this->assertEquals($this->poolProduct->id, $cartItem->purchasable_id); + $this->assertEquals(1, $cartItem->quantity); + $this->assertEquals(3000, $cartItem->price); // 30.00 per day × 1 day + $this->assertEquals(3000, $cartItem->subtotal); // 30.00 × 1 quantity + $this->assertNull($cartItem->from); + $this->assertNull($cartItem->until); + } + + /** @test */ + public function it_adds_pool_with_inherited_price_to_cart_without_dates() + { + // Set prices on single items (20€ and 50€) + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, // 20.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // 50.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Pool should inherit average: (2000 + 5000) / 2 = 3500 + $cartItem = $this->cart->addToCart($this->poolProduct, 1); + + $this->assertNotNull($cartItem); + $this->assertEquals($this->poolProduct->id, $cartItem->purchasable_id); + $this->assertEquals(1, $cartItem->quantity); + $this->assertEquals(3500, $cartItem->price); // Average: 35.00 + $this->assertEquals(3500, $cartItem->subtotal); + } + + /** @test */ + public function it_adds_pool_with_direct_price_to_cart_with_booking_dates() + { + // Set direct price on pool + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, // 30.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1)->startOfDay(); + $until = Carbon::now()->addDays(4)->startOfDay(); // 3 days + $days = $from->diffInDays($until); + + $cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); + + $this->assertNotNull($cartItem); + $this->assertEquals($this->poolProduct->id, $cartItem->purchasable_id); + $this->assertEquals(1, $cartItem->quantity); + $this->assertEquals(9000, $cartItem->price); // 30.00 × 3 days + $this->assertEquals(9000, $cartItem->subtotal); // 90.00 + $this->assertEquals($from->format('Y-m-d H:i:s'), $cartItem->from->format('Y-m-d H:i:s')); + $this->assertEquals($until->format('Y-m-d H:i:s'), $cartItem->until->format('Y-m-d H:i:s')); + } + + /** @test */ + public function it_adds_pool_with_inherited_price_to_cart_with_booking_dates() + { + // Set prices on single items (20€ and 50€) + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, // 20.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // 50.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1)->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days + $days = $from->diffInDays($until); + + // Pool inherits average: (2000 + 5000) / 2 = 3500 per day + $cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); + + $this->assertNotNull($cartItem); + $this->assertEquals($this->poolProduct->id, $cartItem->purchasable_id); + $this->assertEquals(1, $cartItem->quantity); + $this->assertEquals(7000, $cartItem->price); // 35.00 × 2 days + $this->assertEquals(7000, $cartItem->subtotal); + $this->assertEquals($from->format('Y-m-d H:i:s'), $cartItem->from->format('Y-m-d H:i:s')); + $this->assertEquals($until->format('Y-m-d H:i:s'), $cartItem->until->format('Y-m-d H:i:s')); + } + + /** @test */ + public function it_calculates_price_for_multiple_pool_items_with_booking_dates() + { + // Set direct price on pool + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2500, // 25.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1)->startOfDay(); + $until = Carbon::now()->addDays(6)->startOfDay(); // 5 days + + $cartItem = $this->cart->addToCart($this->poolProduct, 2, [], $from, $until); + + $this->assertEquals(2, $cartItem->quantity); + $this->assertEquals(12500, $cartItem->price); // 25.00 × 5 days per unit + $this->assertEquals(25000, $cartItem->subtotal); // 125.00 × 2 units = 250.00 + } + + /** @test */ + public function it_uses_lowest_pricing_strategy_with_mixed_single_item_prices() + { + // Set prices on single items (20€ and 50€) + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, // 20.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // 50.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Set pricing strategy to lowest + $this->poolProduct->setPoolPricingStrategy('lowest'); + + $cartItem = $this->cart->addToCart($this->poolProduct, 1); + + $this->assertEquals(2000, $cartItem->price); // Lowest: 20.00 + $this->assertEquals(2000, $cartItem->subtotal); + } + + /** @test */ + public function it_uses_highest_pricing_strategy_with_mixed_single_item_prices() + { + // Set prices on single items (20€ and 50€) + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, // 20.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // 50.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Set pricing strategy to highest + $this->poolProduct->setPoolPricingStrategy('highest'); + + $cartItem = $this->cart->addToCart($this->poolProduct, 1); + + $this->assertEquals(5000, $cartItem->price); // Highest: 50.00 + $this->assertEquals(5000, $cartItem->subtotal); + } + + /** @test */ + public function it_uses_lowest_pricing_strategy_with_booking_dates() + { + // Set prices on single items (20€ and 50€) + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, // 20.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // 50.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Set pricing strategy to lowest + $this->poolProduct->setPoolPricingStrategy('lowest'); + + $from = Carbon::now()->addDays(1)->startOfDay(); + $until = Carbon::now()->addDays(4)->startOfDay(); // 3 days + + $cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); + + $this->assertEquals(6000, $cartItem->price); // 20.00 × 3 days + $this->assertEquals(6000, $cartItem->subtotal); + } + + /** @test */ + public function it_adds_regular_product_to_cart_without_dates() + { + $regularProduct = Product::factory()->create([ + 'name' => 'Regular Product', + 'type' => ProductType::SIMPLE, + 'manage_stock' => true, + ]); + $regularProduct->increaseStock(10); + + ProductPrice::factory()->create([ + 'purchasable_id' => $regularProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1500, // 15.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + $cartItem = $this->cart->addToCart($regularProduct, 2); + + $this->assertEquals(2, $cartItem->quantity); + $this->assertEquals(1500, $cartItem->price); + $this->assertEquals(3000, $cartItem->subtotal); + $this->assertNull($cartItem->from); + $this->assertNull($cartItem->until); + } + + /** @test */ + public function it_increases_quantity_when_adding_same_pool_product_with_same_dates() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1)->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); + + $cartItem1 = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); + $cartItem2 = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); + + $this->assertEquals($cartItem1->id, $cartItem2->id); + $this->assertEquals(2, $cartItem2->quantity); + $this->assertEquals(12000, $cartItem2->subtotal); // 3000 × 2 days × 2 units + } + + /** @test */ + public function it_creates_separate_cart_items_for_same_pool_with_different_dates() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from1 = Carbon::now()->addDays(1)->startOfDay(); + $until1 = Carbon::now()->addDays(3)->startOfDay(); + + $from2 = Carbon::now()->addDays(5)->startOfDay(); + $until2 = Carbon::now()->addDays(7)->startOfDay(); + + $cartItem1 = $this->cart->addToCart($this->poolProduct, 1, [], $from1, $until1); + $cartItem2 = $this->cart->addToCart($this->poolProduct, 1, [], $from2, $until2); + + $this->assertNotEquals($cartItem1->id, $cartItem2->id); + $this->assertEquals(1, $cartItem1->quantity); + $this->assertEquals(1, $cartItem2->quantity); + $this->assertEquals(2, $this->cart->items()->count()); + } + + /** @test */ + public function it_calculates_correct_total_for_cart_with_multiple_pool_items() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2500, // 25.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1)->startOfDay(); + $until = Carbon::now()->addDays(4)->startOfDay(); // 3 days + + // Add 2 units + $this->cart->addToCart($this->poolProduct, 2, [], $from, $until); + + $total = $this->cart->getTotal(); + + // 25.00 × 3 days × 2 units = 150.00 + $this->assertEquals(15000, $total); + } + + /** @test */ + public function it_handles_pool_with_sale_price() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // 50.00 + 'sale_unit_amount' => 3000, // 30.00 (sale) + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Set sale period + $this->poolProduct->update([ + 'sale_start' => now()->subDay(), + 'sale_end' => now()->addDay(), + ]); + + $from = Carbon::now()->addDays(1)->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days + + $cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); + + $this->assertEquals(6000, $cartItem->price); // 30.00 × 2 days (sale price) + $this->assertEquals(10000, $cartItem->regular_price); // 50.00 × 2 days (regular price) + } + + /** @test */ + public function it_handles_pool_with_inherited_sale_price() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'sale_unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 7000, + 'sale_unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Set sale period on single items + $this->singleItem1->update([ + 'sale_start' => now()->subDay(), + 'sale_end' => now()->addDay(), + ]); + + $this->singleItem2->update([ + 'sale_start' => now()->subDay(), + 'sale_end' => now()->addDay(), + ]); + + $from = Carbon::now()->addDays(1)->startOfDay(); + $until = Carbon::now()->addDays(2)->startOfDay(); // 1 day + + $cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); + + // Average sale price: (3000 + 5000) / 2 = 4000 per day + $this->assertEquals(4000, $cartItem->price); + // Average regular price: (5000 + 7000) / 2 = 6000 per day + $this->assertEquals(6000, $cartItem->regular_price); + } + + /** @test */ + public function it_handles_zero_days_as_one_day_minimum() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Same day booking (0 days diff) + $from = Carbon::now()->addDays(1)->setTime(10, 0); + $until = Carbon::now()->addDays(1)->setTime(14, 0); + + $cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); + + // Should treat as minimum 1 day + $this->assertEquals(3000, $cartItem->price); // 30.00 × 1 day + } + + /** @test */ + public function it_throws_exception_when_adding_pool_without_any_pricing() + { + // Pool with no direct price and single items with no prices + $pool = Product::factory()->create([ + 'name' => 'Pool Without Pricing', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $spot = Product::factory()->create([ + 'name' => 'Spot Without Price', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot->increaseStock(1); + + $pool->productRelations()->attach($spot->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + $this->expectException(\Blax\Shop\Exceptions\HasNoPriceException::class); + $this->cart->addToCart($pool, 1); + } + + /** @test */ + public function it_throws_exception_when_pool_not_available_for_booking_period() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1)->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); + + // Claim all single items for the period + $this->singleItem1->claimStock(1, null, $from, $until); + $this->singleItem2->claimStock(1, null, $from, $until); + + // Try to add pool for same period + $this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class); + $this->expectExceptionMessage('has only 0 items available'); + $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); + } + + /** @test */ + public function it_throws_exception_when_booking_product_not_available_for_period() + { + $bookingProduct = Product::factory()->create([ + 'name' => 'Meeting Room', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $bookingProduct->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $bookingProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1)->startOfDay(); + $until = Carbon::now()->addDays(3)->startOfDay(); + + // Claim the booking product for the period + $bookingProduct->claimStock(1, null, $from, $until); + + // Try to add for overlapping period + $this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class); + $this->expectExceptionMessage('not available for the requested period'); + $this->cart->addToCart($bookingProduct, 1, [], $from, $until); + } + + /** @test */ + public function it_throws_exception_when_only_from_date_provided() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Both 'from' and 'until' dates must be provided together"); + $this->cart->addToCart($this->poolProduct, 1, [], $from, null); + } + + /** @test */ + public function it_throws_exception_when_only_until_date_provided() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $until = Carbon::now()->addDays(3); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Both 'from' and 'until' dates must be provided together"); + $this->cart->addToCart($this->poolProduct, 1, [], null, $until); + } + + /** @test */ + public function it_throws_exception_when_from_is_after_until() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(5); + $until = Carbon::now()->addDays(2); // Before from + + $this->expectException(\Exception::class); + $this->expectExceptionMessage("'from' date must be before the 'until' date"); + $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); + } + + /** @test */ + public function it_throws_exception_when_from_equals_until() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $date = Carbon::now()->addDays(1); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage("'from' date must be before the 'until' date"); + $this->cart->addToCart($this->poolProduct, 1, [], $date, $date); + } + + /** @test */ + public function it_creates_separate_items_for_same_product_same_dates_different_parameters() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cartItem1 = $this->cart->addToCart($this->poolProduct, 1, ['zone' => 'A'], $from, $until); + $cartItem2 = $this->cart->addToCart($this->poolProduct, 1, ['zone' => 'B'], $from, $until); + + $this->assertNotEquals($cartItem1->id, $cartItem2->id); + $this->assertEquals(2, $this->cart->items()->count()); + $this->assertEquals(['zone' => 'A'], $cartItem1->parameters); + $this->assertEquals(['zone' => 'B'], $cartItem2->parameters); + } + + /** @test */ + public function it_throws_exception_when_pool_quantity_exceeds_available_items() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Pool has 2 single items, try to add 3 (with dates to check availability) + $this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class); + $this->expectExceptionMessage('has only 2 items available'); + $this->cart->addToCart($this->poolProduct, 3, [], $from, $until); + } + + /** @test */ + public function it_handles_partial_pool_availability() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + // Claim one of the two single items + $this->singleItem1->claimStock(1, null, $from, $until); + + // Should be able to add 1 (one spot still available) + $cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); + $this->assertNotNull($cartItem); + + // But not 2 + $this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class); + $this->cart->addToCart($this->poolProduct, 2, [], $from, $until); + } + + /** @test */ + public function it_throws_exception_for_regular_product_without_price() + { + $regularProduct = Product::factory()->create([ + 'name' => 'Product Without Price', + 'type' => ProductType::SIMPLE, + 'manage_stock' => true, + ]); + $regularProduct->increaseStock(10); + + $this->expectException(\Blax\Shop\Exceptions\HasNoPriceException::class); + $this->cart->addToCart($regularProduct, 1); + } + + /** @test */ + public function it_allows_adding_booking_product_without_dates() + { + $bookingProduct = Product::factory()->create([ + 'name' => 'Meeting Room', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $bookingProduct->increaseStock(5); + + ProductPrice::factory()->create([ + 'purchasable_id' => $bookingProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Should be able to add without dates + $cartItem = $this->cart->addToCart($bookingProduct, 1); + + $this->assertNotNull($cartItem); + $this->assertEquals($bookingProduct->id, $cartItem->purchasable_id); + $this->assertNull($cartItem->from); + $this->assertNull($cartItem->until); + $this->assertEquals(5000, $cartItem->price); // 1 day default + } + + /** @test */ + public function it_allows_adding_pool_product_without_dates() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Should be able to add without dates + $cartItem = $this->cart->addToCart($this->poolProduct, 1); + + $this->assertNotNull($cartItem); + $this->assertEquals($this->poolProduct->id, $cartItem->purchasable_id); + $this->assertNull($cartItem->from); + $this->assertNull($cartItem->until); + $this->assertEquals(3000, $cartItem->price); // 1 day default + } + + /** @test */ + public function it_allows_updating_cart_item_dates_later() + { + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Add without dates + $cartItem = $this->cart->addToCart($this->poolProduct, 1); + $this->assertNull($cartItem->from); + $this->assertNull($cartItem->until); + + // Update with dates + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $cartItem->update([ + 'from' => $from, + 'until' => $until, + 'price' => 3000 * 2, // 2 days + 'subtotal' => 3000 * 2 * 1, // 2 days × 1 quantity + ]); + + $cartItem->refresh(); + $this->assertEquals($from->format('Y-m-d H:i:s'), $cartItem->from->format('Y-m-d H:i:s')); + $this->assertEquals($until->format('Y-m-d H:i:s'), $cartItem->until->format('Y-m-d H:i:s')); + $this->assertEquals(6000, $cartItem->price); + } +} diff --git a/tests/Feature/CartItemRequiredAdjustmentsTest.php b/tests/Feature/CartItemRequiredAdjustmentsTest.php new file mode 100644 index 0000000..ccc64dd --- /dev/null +++ b/tests/Feature/CartItemRequiredAdjustmentsTest.php @@ -0,0 +1,535 @@ +user = User::factory()->create(); + $this->cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + } + + /** @test */ + public function it_returns_empty_array_for_simple_product() + { + $product = Product::factory()->create([ + 'type' => ProductType::SIMPLE, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // 50.00 + 'currency' => 'USD', + 'is_default' => true, + ]); + + $cartItem = $this->cart->addToCart($product, 1); + + $adjustments = $cartItem->requiredAdjustments(); + + $this->assertIsArray($adjustments); + $this->assertEmpty($adjustments); + } + + /** @test */ + public function it_returns_from_and_until_for_booking_product_without_dates() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + ]); + + 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); + + $adjustments = $cartItem->requiredAdjustments(); + + $this->assertEquals([ + 'from' => 'datetime', + 'until' => 'datetime', + ], $adjustments); + } + + /** @test */ + public function it_returns_only_until_for_booking_product_with_from_date() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1); + + $cartItem = $this->cart->addToCart($product, 1); + $cartItem->update(['from' => $from]); + + $adjustments = $cartItem->requiredAdjustments(); + + $this->assertEquals([ + 'until' => 'datetime', + ], $adjustments); + } + + /** @test */ + public function it_returns_only_from_for_booking_product_with_until_date() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $until = Carbon::now()->addDays(5); + + $cartItem = $this->cart->addToCart($product, 1); + $cartItem->update(['until' => $until]); + + $adjustments = $cartItem->requiredAdjustments(); + + $this->assertEquals([ + 'from' => 'datetime', + ], $adjustments); + } + + /** @test */ + public function it_returns_empty_array_for_booking_product_with_both_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' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(5); + + $cartItem = $this->cart->addToCart($product, 1, [], $from, $until); + + $adjustments = $cartItem->requiredAdjustments(); + + $this->assertEmpty($adjustments); + } + + /** @test */ + public function it_returns_dates_for_pool_with_booking_single_items_without_dates() + { + // Create pool product + $poolProduct = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + ]); + + // Create booking single items + $singleItem1 = Product::factory()->create([ + 'name' => 'Parking Spot 1', + 'type' => ProductType::BOOKING, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $singleItem2 = Product::factory()->create([ + 'name' => 'Parking Spot 2', + 'type' => ProductType::BOOKING, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Attach single items to pool + $poolProduct->productRelations()->attach($singleItem1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + $poolProduct->productRelations()->attach($singleItem2->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + $cartItem = $this->cart->addToCart($poolProduct, 1); + + $adjustments = $cartItem->requiredAdjustments(); + + $this->assertEquals([ + 'from' => 'datetime', + 'until' => 'datetime', + ], $adjustments); + } + + /** @test */ + public function it_returns_empty_array_for_pool_with_booking_items_with_dates() + { + // Create pool product + $poolProduct = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + ]); + + // Create booking single items + $singleItem = Product::factory()->create([ + 'name' => 'Parking Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + + $singleItem->increaseStock(10); + + ProductPrice::factory()->create([ + 'purchasable_id' => $singleItem->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Attach single item to pool + $poolProduct->productRelations()->attach($singleItem->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(5); + + $cartItem = $this->cart->addToCart($poolProduct, 1, [], $from, $until); + + $adjustments = $cartItem->requiredAdjustments(); + + $this->assertEmpty($adjustments); + } + + /** @test */ + public function it_returns_empty_array_for_pool_with_simple_single_items() + { + // Create pool product + $poolProduct = Product::factory()->create([ + 'name' => 'Product Bundle', + 'type' => ProductType::POOL, + ]); + + // Create simple single items + $singleItem1 = Product::factory()->create([ + 'name' => 'Item 1', + 'type' => ProductType::SIMPLE, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $singleItem2 = Product::factory()->create([ + 'name' => 'Item 2', + 'type' => ProductType::SIMPLE, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Attach single items to pool + $poolProduct->productRelations()->attach($singleItem1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + $poolProduct->productRelations()->attach($singleItem2->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + $cartItem = $this->cart->addToCart($poolProduct, 1); + + $adjustments = $cartItem->requiredAdjustments(); + + $this->assertEmpty($adjustments); + } + + /** @test */ + public function it_returns_dates_for_pool_with_mixed_single_items_containing_bookings() + { + // Create pool product + $poolProduct = Product::factory()->create([ + 'name' => 'Mixed Pool', + 'type' => ProductType::POOL, + ]); + + // Create simple single item + $simpleItem = Product::factory()->create([ + 'name' => 'Simple Item', + 'type' => ProductType::SIMPLE, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $simpleItem->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Create booking single item + $bookingItem = Product::factory()->create([ + 'name' => 'Booking Item', + 'type' => ProductType::BOOKING, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $bookingItem->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Attach single items to pool + $poolProduct->productRelations()->attach($simpleItem->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + $poolProduct->productRelations()->attach($bookingItem->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + $cartItem = $this->cart->addToCart($poolProduct, 1); + + $adjustments = $cartItem->requiredAdjustments(); + + // Even though it has simple items, the booking item requires dates + $this->assertEquals([ + 'from' => 'datetime', + 'until' => 'datetime', + ], $adjustments); + } + + /** @test */ + public function it_returns_empty_array_for_non_product_purchasable() + { + // Create a cart item with a non-product purchasable + $cartItem = new CartItem([ + 'cart_id' => $this->cart->id, + 'purchasable_id' => 1, + 'purchasable_type' => 'App\\Models\\Subscription', // Not a product + 'quantity' => 1, + 'price' => 1000, + ]); + $cartItem->save(); + + $adjustments = $cartItem->requiredAdjustments(); + + $this->assertEmpty($adjustments); + } + + /** @test */ + public function it_handles_null_purchasable_gracefully() + { + // Create a cart item with invalid purchasable_id + $cartItem = new CartItem([ + 'cart_id' => $this->cart->id, + 'purchasable_id' => 99999, + 'purchasable_type' => config('shop.models.product', Product::class), + 'quantity' => 1, + 'price' => 1000, + ]); + $cartItem->save(); + + $adjustments = $cartItem->requiredAdjustments(); + + $this->assertEmpty($adjustments); + } + + /** @test */ + public function it_can_validate_entire_cart_before_checkout() + { + // Create mixed cart with booking and simple products + $simpleProduct = Product::factory()->create([ + 'type' => ProductType::SIMPLE, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $simpleProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $bookingProduct = Product::factory()->create([ + 'type' => ProductType::BOOKING, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $bookingProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Add both to cart + $this->cart->addToCart($simpleProduct, 1); + $this->cart->addToCart($bookingProduct, 1); + + // Check which items need adjustments + $itemsNeedingAdjustments = $this->cart->items->filter(function ($item) { + return !empty($item->requiredAdjustments()); + }); + + // Only the booking product should need adjustments + $this->assertCount(1, $itemsNeedingAdjustments); + $this->assertEquals($bookingProduct->id, $itemsNeedingAdjustments->first()->purchasable_id); + } + + /** @test */ + public function cart_can_get_items_requiring_adjustments() + { + $simpleProduct = Product::factory()->create([ + 'type' => ProductType::SIMPLE, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $simpleProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $bookingProduct = Product::factory()->create([ + 'type' => ProductType::BOOKING, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $bookingProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $this->cart->addToCart($simpleProduct, 1); + $this->cart->addToCart($bookingProduct, 1); + + $incompleteItems = $this->cart->getItemsRequiringAdjustments(); + + $this->assertCount(1, $incompleteItems); + $this->assertEquals($bookingProduct->id, $incompleteItems->first()->purchasable_id); + } + + /** @test */ + public function cart_is_not_ready_for_checkout_when_items_need_adjustments() + { + $bookingProduct = Product::factory()->create([ + 'type' => ProductType::BOOKING, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $bookingProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $this->cart->addToCart($bookingProduct, 1); + + $this->assertFalse($this->cart->isReadyForCheckout()); + } + + /** @test */ + public function cart_is_ready_for_checkout_when_all_items_complete() + { + $simpleProduct = Product::factory()->create([ + 'type' => ProductType::SIMPLE, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $simpleProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $bookingProduct = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + + $bookingProduct->increaseStock(10); + + ProductPrice::factory()->create([ + 'purchasable_id' => $bookingProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(5); + + $this->cart->addToCart($simpleProduct, 1); + $this->cart->addToCart($bookingProduct, 1, [], $from, $until); + + $this->assertTrue($this->cart->isReadyForCheckout()); + } +} diff --git a/tests/Feature/CartManagementTest.php b/tests/Feature/CartManagementTest.php index a51a929..d43f1ca 100644 --- a/tests/Feature/CartManagementTest.php +++ b/tests/Feature/CartManagementTest.php @@ -45,22 +45,12 @@ class CartManagementTest extends TestCase /** @test */ public function it_can_add_items_to_cart() { - $product = Product::factory()->create(); - $price = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - ]); + $product = Product::factory()->withPrices()->create(); + $price = $product->defaultPrice()->first(); $cart = Cart::create(); - $cartItem = CartItem::create([ - 'cart_id' => $cart->id, - 'purchasable_id' => $price->id, - 'purchasable_type' => get_class($price), - 'quantity' => 2, - 'price' => $price->unit_amount, - 'subtotal' => $price->unit_amount * 2, - ]); + $cartItem = $cart->addToCart($price, quantity: 2); $this->assertCount(1, $cart->fresh()->items); $this->assertEquals(2, $cart->items->first()->quantity); @@ -70,12 +60,8 @@ class CartManagementTest extends TestCase public function it_can_update_cart_item_quantity() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 50.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $price = $product->defaultPrice()->first(); $cartItem = $cart->addToCart($price, quantity: 1); $cartItem->update(['quantity' => 3]); @@ -87,12 +73,8 @@ class CartManagementTest extends TestCase public function it_can_remove_items_from_cart() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 100.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 100.00)->create(); + $price = $product->defaultPrice()->first(); $cartItem = $cart->addToCart($price, quantity: 1); @@ -107,20 +89,11 @@ class CartManagementTest extends TestCase public function it_calculates_cart_total_correctly() { $cart = Cart::create(); - $product1 = Product::factory()->create(); - $product2 = Product::factory()->create(); + $product1 = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $product2 = Product::factory()->withPrices(unit_amount: 30.00)->create(); - $productPrice1 = ProductPrice::factory()->create([ - 'purchasable_id' => $product1->id, - 'purchasable_type' => get_class($product1), - 'unit_amount' => 50.00, - ]); - - $productPrice2 = ProductPrice::factory()->create([ - 'purchasable_id' => $product2->id, - 'purchasable_type' => get_class($product2), - 'unit_amount' => 30.00, - ]); + $productPrice1 = $product1->defaultPrice()->first(); + $productPrice2 = $product2->defaultPrice()->first(); $cart->addToCart($productPrice1, quantity: 2); $cart->addToCart($productPrice2, quantity: 1); @@ -134,20 +107,11 @@ class CartManagementTest extends TestCase public function it_calculates_total_items_correctly() { $cart = Cart::create(); - $product1 = Product::factory()->create(); - $product2 = Product::factory()->create(); + $product1 = Product::factory()->withPrices(unit_amount: 10.00)->create(); + $product2 = Product::factory()->withPrices(unit_amount: 20.00)->create(); - $product1Price = ProductPrice::factory()->create([ - 'purchasable_id' => $product1->id, - 'purchasable_type' => get_class($product1), - 'unit_amount' => 10.00, - ]); - - $product2Price = ProductPrice::factory()->create([ - 'purchasable_id' => $product2->id, - 'purchasable_type' => get_class($product2), - 'unit_amount' => 20.00, - ]); + $product1Price = $product1->defaultPrice()->first(); + $product2Price = $product2->defaultPrice()->first(); $cart->addToCart($product1Price, quantity: 3); $cart->addToCart($product2Price, quantity: 2); @@ -238,13 +202,8 @@ class CartManagementTest extends TestCase public function cart_items_have_correct_relationships() { $cart = Cart::create(); - $product = Product::factory()->create(); - - $productPrice = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 45.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 45.00)->create(); + $productPrice = $product->defaultPrice()->first(); $cartItem = $cart->addToCart($productPrice, quantity: 1); @@ -256,13 +215,8 @@ class CartManagementTest extends TestCase public function it_calculates_cart_item_subtotal() { $cart = Cart::create(); - $product = Product::factory()->create(); - - $productPrice = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 25.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 25.00)->create(); + $productPrice = $product->defaultPrice()->first(); $cartItem = $cart->addToCart($productPrice, quantity: 4); @@ -273,13 +227,8 @@ class CartManagementTest extends TestCase public function it_can_store_cart_item_attributes() { $cart = Cart::create(); - $product = Product::factory()->create(); - - $productPrice = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 50.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $productPrice = $product->defaultPrice()->first(); $cartItem = $cart->addToCart( $productPrice, @@ -298,13 +247,8 @@ class CartManagementTest extends TestCase public function it_can_have_multiple_items_of_same_product_with_different_attributes() { $cart = Cart::create(); - $product = Product::factory()->create(); - - $productPrice = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 30.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 30.00)->create(); + $productPrice = $product->defaultPrice()->first(); $cart->addToCart( $productPrice, @@ -326,13 +270,8 @@ class CartManagementTest extends TestCase public function it_deletes_cart_items_when_cart_is_deleted() { $cart = Cart::create(); - $product = Product::factory()->create(); - - $productPrice = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 75.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 75.00)->create(); + $productPrice = $product->defaultPrice()->first(); $cartItem = $cart->addToCart( $productPrice, @@ -398,15 +337,11 @@ class CartManagementTest extends TestCase } /** @test */ - public function it_can_remove_item_from_cart_completely() + public function it_can_remove_entire_cart_item() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 50.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $price = $product->defaultPrice()->first(); $cartItem = $cart->addToCart($price, quantity: 2); $this->assertCount(1, $cart->items); @@ -421,12 +356,8 @@ class CartManagementTest extends TestCase public function it_can_decrease_cart_item_quantity() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 75.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 75.00)->create(); + $price = $product->defaultPrice()->first(); $cartItem = $cart->addToCart($price, quantity: 5); $this->assertEquals(5, $cartItem->quantity); @@ -442,12 +373,8 @@ class CartManagementTest extends TestCase public function it_updates_subtotal_correctly_when_decreasing_quantity() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 100.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 100.00)->create(); + $price = $product->defaultPrice()->first(); $cart->addToCart($price, quantity: 4); @@ -462,12 +389,8 @@ class CartManagementTest extends TestCase public function it_respects_parameters_when_removing_from_cart() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 50.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $price = $product->defaultPrice()->first(); // Add same product with different parameters $cartItem1 = $cart->addToCart( @@ -496,12 +419,8 @@ class CartManagementTest extends TestCase public function it_decreases_only_matching_parameters_when_removing() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 50.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $price = $product->defaultPrice()->first(); $cart->addToCart( $price, @@ -520,12 +439,8 @@ class CartManagementTest extends TestCase public function it_returns_cart_item_when_quantity_is_decreased() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 50.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $price = $product->defaultPrice()->first(); $cart->addToCart($price, quantity: 5); @@ -539,12 +454,8 @@ class CartManagementTest extends TestCase public function it_handles_removing_nonexistent_item_gracefully() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 50.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $price = $product->defaultPrice()->first(); $result = $cart->removeFromCart($price, quantity: 1); @@ -557,12 +468,8 @@ class CartManagementTest extends TestCase public function it_updates_cart_total_after_removing_items() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::factory()->create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 50.00, - ]); + $product = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $price = $product->defaultPrice()->first(); $cart->addToCart($price, quantity: 5); $this->assertEquals(250.00, $cart->getTotal()); @@ -576,20 +483,11 @@ class CartManagementTest extends TestCase public function it_can_remove_from_cart_with_multiple_items() { $cart = Cart::create(); - $product1 = Product::factory()->create(); - $product2 = Product::factory()->create(); + $product1 = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $product2 = Product::factory()->withPrices(unit_amount: 75.00)->create(); - $price1 = ProductPrice::factory()->create([ - 'purchasable_id' => $product1->id, - 'purchasable_type' => get_class($product1), - 'unit_amount' => 50.00, - ]); - - $price2 = ProductPrice::factory()->create([ - 'purchasable_id' => $product2->id, - 'purchasable_type' => get_class($product2), - 'unit_amount' => 75.00, - ]); + $price1 = $product1->defaultPrice()->first(); + $price2 = $product2->defaultPrice()->first(); $cart->addToCart($price1, quantity: 2); $cart->addToCart($price2, quantity: 3); diff --git a/tests/Feature/ProductManagementTest.php b/tests/Feature/ProductManagementTest.php index da80b32..aa49fea 100644 --- a/tests/Feature/ProductManagementTest.php +++ b/tests/Feature/ProductManagementTest.php @@ -130,7 +130,7 @@ class ProductManagementTest extends TestCase { $product = Product::factory()->create(); - ProductPrice::create([ + ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => get_class($product), 'type' => 'one_time', @@ -139,11 +139,11 @@ class ProductManagementTest extends TestCase 'active' => true, ]); - ProductPrice::create([ + ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => get_class($product), 'type' => 'recurring', - 'price' => 1999, + 'unit_amount' => 1999, 'currency' => 'USD', 'interval' => 'month', 'active' => true, diff --git a/tests/Feature/PurchaseFlowTest.php b/tests/Feature/PurchaseFlowTest.php index 04e6b2b..9f53769 100644 --- a/tests/Feature/PurchaseFlowTest.php +++ b/tests/Feature/PurchaseFlowTest.php @@ -21,16 +21,11 @@ class PurchaseFlowTest extends TestCase public function user_can_purchase_a_product_directly() { $user = User::factory()->create(); - $product = Product::factory()->create([ + $product = Product::factory()->withPrices(unit_amount: 49.99)->create([ 'manage_stock' => false, ]); - $price = ProductPrice::create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'amount' => 4999, // in cents - 'currency' => 'USD', - ]); + $price = $product->defaultPrice()->first(); $purchase = $user->purchase($price, quantity: 1); diff --git a/tests/Unit/CartTest.php b/tests/Unit/CartTest.php index 0e978d2..8c345c5 100644 --- a/tests/Unit/CartTest.php +++ b/tests/Unit/CartTest.php @@ -17,13 +17,8 @@ class CartTest extends TestCase public function cart_can_add_product_price_directly() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 100.00, - 'currency' => 'USD', - ]); + $product = Product::factory()->withPrices(unit_amount: 100.00)->create(); + $price = $product->defaultPrice()->first(); $cartItem = $cart->addToCart($price, quantity: 2); @@ -36,13 +31,8 @@ class CartTest extends TestCase public function cart_calculates_subtotal_automatically() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 50.00, - 'currency' => 'USD', - ]); + $product = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $price = $product->defaultPrice()->first(); $cartItem = $cart->addToCart($price, quantity: 3); @@ -62,7 +52,8 @@ class CartTest extends TestCase 'is_default' => false, ]); - $price = ProductPrice::create([ + // Create a second price using factory + $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => get_class($product), 'unit_amount' => 100.00, @@ -85,13 +76,8 @@ class CartTest extends TestCase public function cart_can_add_items_with_custom_parameters() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 50.00, - 'currency' => 'USD', - ]); + $product = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $price = $product->defaultPrice()->first(); $parameters = [ 'color' => 'red', @@ -111,21 +97,11 @@ class CartTest extends TestCase { $cart = Cart::create(); - $product1 = Product::factory()->create(); - $price1 = ProductPrice::create([ - 'purchasable_id' => $product1->id, - 'purchasable_type' => get_class($product1), - 'unit_amount' => 25.00, - 'currency' => 'USD', - ]); + $product1 = Product::factory()->withPrices(unit_amount: 25.00)->create(); + $price1 = $product1->defaultPrice()->first(); - $product2 = Product::factory()->create(); - $price2 = ProductPrice::create([ - 'purchasable_id' => $product2->id, - 'purchasable_type' => get_class($product2), - 'unit_amount' => 50.00, - 'currency' => 'USD', - ]); + $product2 = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $price2 = $product2->defaultPrice()->first(); $cart->addToCart($price1, quantity: 2); // 50 $cart->addToCart($price2, quantity: 3); // 150 @@ -194,13 +170,8 @@ class CartTest extends TestCase public function cart_deletes_items_on_deletion() { $cart = Cart::create(); - $product = Product::factory()->create(); - $price = ProductPrice::create([ - 'purchasable_id' => $product->id, - 'purchasable_type' => get_class($product), - 'unit_amount' => 50.00, - 'currency' => 'USD', - ]); + $product = Product::factory()->withPrices(unit_amount: 50.00)->create(); + $price = $product->defaultPrice()->first(); $cartItem = $cart->addToCart($price); $cartItemId = $cartItem->id;