From 3d7a273946809384dd863c4c30d3c20627185605 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Wed, 17 Dec 2025 16:43:22 +0100 Subject: [PATCH] BFI cart & tests --- database/factories/ProductFactory.php | 45 ++- database/factories/ProductPriceFactory.php | 29 +- src/Exceptions/HasNoPriceException.php | 11 +- src/Models/Cart.php | 38 +- src/Models/CartItem.php | 33 +- src/Models/Product.php | 17 +- src/Traits/MayBePoolProduct.php | 22 ++ tests/Feature/CartDateStringParsingTest.php | 204 +++++++++++ .../PoolProductPricingFlexibilityTest.php | 336 ++++++++++++++++++ .../Feature/ProductPricingValidationTest.php | 4 +- 10 files changed, 700 insertions(+), 39 deletions(-) create mode 100644 tests/Feature/CartDateStringParsingTest.php create mode 100644 tests/Feature/PoolProductPricingFlexibilityTest.php diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index 12a2ee2..24865b9 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -13,11 +13,35 @@ class ProductFactory extends Factory public function definition(): array { - $name = $this->faker->words(3, true); + // Generate realistic product names + $productTypes = [ + 'Laptop', + 'Smartphone', + 'Headphones', + 'Camera', + 'Tablet', + 'Watch', + 'Monitor', + 'Keyboard', + 'Mouse', + 'Speaker', + 'Charger', + 'Cable', + 'Case', + 'Stand', + 'Adapter' + ]; + + $adjectives = ['Pro', 'Max', 'Plus', 'Ultra', 'Premium', 'Deluxe']; + + $productType = $this->faker->randomElement($productTypes); + $adjective = $this->faker->optional(0.6)->randomElement($adjectives); + + $name = $adjective ? "{$productType} {$adjective}" : $productType; return [ - 'name' => ucfirst($name), - 'slug' => Str::slug($name), + 'name' => $name, + 'slug' => Str::slug($name . '-' . $this->faker->unique()->numberBetween(1000, 9999)), 'sku' => strtoupper($this->faker->bothify('??-####')), 'type' => 'simple', 'status' => 'published', @@ -62,12 +86,25 @@ class ProductFactory extends Factory null|float $sale_unit_amount = null ): static { return $this->afterCreating(function (Product $product) use ($count, $unit_amount, $sale_unit_amount) { + // Use realistic price range if not specified + $defaultPrice = $unit_amount ?? $this->faker->randomElement([ + 1999, // $19.99 + 2999, // $29.99 + 4999, // $49.99 + 7999, // $79.99 + 9999, // $99.99 + 14999, // $149.99 + 19999, // $199.99 + 29999, // $299.99 + 49999, // $499.99 + ]); + $prices = \Blax\Shop\Models\ProductPrice::factory() ->count($count) ->create([ 'purchasable_type' => get_class($product), 'purchasable_id' => $product->id, - 'unit_amount' => $unit_amount ?? $this->faker->randomFloat(2, 10, 1000), + 'unit_amount' => $defaultPrice, 'sale_unit_amount' => $sale_unit_amount, 'currency' => 'EUR', ]); diff --git a/database/factories/ProductPriceFactory.php b/database/factories/ProductPriceFactory.php index 41cfdc8..2d86834 100644 --- a/database/factories/ProductPriceFactory.php +++ b/database/factories/ProductPriceFactory.php @@ -12,16 +12,37 @@ class ProductPriceFactory extends Factory public function definition() { $type = $this->faker->randomElement(['one_time', 'recurring']); - $unit_amount = $this->faker->randomFloat(2, 100, 40000); - $sale_unit_amount = $this->faker->randomFloat(2, $unit_amount * 0.5, $unit_amount * 0.80); + + // Realistic price points (in cents) + $realisticPrices = [ + 999, // $9.99 + 1499, // $14.99 + 1999, // $19.99 + 2499, // $24.99 + 2999, // $29.99 + 3999, // $39.99 + 4999, // $49.99 + 5999, // $59.99 + 7999, // $79.99 + 9999, // $99.99 + 12999, // $129.99 + 14999, // $149.99 + 19999, // $199.99 + 24999, // $249.99 + 29999, // $299.99 + ]; + + $unit_amount = $this->faker->randomElement($realisticPrices); + $sale_unit_amount = $this->faker->optional(0.3)->passthrough( + intval($unit_amount * $this->faker->randomFloat(2, 0.7, 0.9)) + ); return [ 'type' => $type, 'billing_scheme' => $this->faker->randomElement(['per_unit', 'tiered']), - 'unit_amount' => $this->faker->randomFloat(2, 1, 1000), + 'unit_amount' => $unit_amount, 'currency' => 'EUR', 'is_default' => false, - 'unit_amount' => $unit_amount, 'sale_unit_amount' => $sale_unit_amount, 'interval' => $type === 'recurring' ? $this->faker->randomElement(['day', 'week', 'month', 'quarter', 'year']) : null, 'interval_count' => $type === 'recurring' ? $this->faker->numberBetween(1, 12) : null, diff --git a/src/Exceptions/HasNoPriceException.php b/src/Exceptions/HasNoPriceException.php index b1c14c9..71428cc 100644 --- a/src/Exceptions/HasNoPriceException.php +++ b/src/Exceptions/HasNoPriceException.php @@ -29,9 +29,9 @@ class HasNoPriceException extends NotPurchasable public static function poolProductNoPriceAndNoSingleItemPrices(string $productName): self { return new self( - "Pool product '{$productName}' has no pricing configured.\n\n" . - "Pool products need pricing through one of two methods:\n\n" . - "Option 1: Direct pool pricing (Recommended)\n" . + "Cannot add pool product '{$productName}' to cart: No pricing available.\n\n" . + "Pool products can be priced in two ways:\n\n" . + "Option 1: Direct pool pricing\n" . "ProductPrice::create([\n" . " 'purchasable_id' => \$poolProduct->id,\n" . " 'purchasable_type' => Product::class,\n" . @@ -39,7 +39,7 @@ class HasNoPriceException extends NotPurchasable " 'currency' => 'USD',\n" . " 'is_default' => true,\n" . "]);\n\n" . - "Option 2: Price inheritance from single items\n" . + "Option 2: Price inheritance from single items (Recommended)\n" . "// Set prices on individual items in the pool\n" . "foreach (\$poolProduct->singleProducts as \$item) {\n" . " ProductPrice::create([\n" . @@ -54,7 +54,8 @@ class HasNoPriceException extends NotPurchasable "\$poolProduct->setPoolPricingStrategy('average'); // or 'lowest' or 'highest'\n\n" . "Current state:\n" . "- Pool product has no direct price\n" . - "- No single items have prices to inherit from" + "- No single items have prices to inherit from\n\n" . + "At least one pricing method must be configured before adding to cart." ); } } diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 4de90a1..d53bc1a 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -186,15 +186,23 @@ class Cart extends Model * Set the default date range for the cart. * Items without specific dates will use these as fallback. * - * @param \DateTimeInterface $from Start date - * @param \DateTimeInterface $until End date + * @param \DateTimeInterface|string $from Start date (DateTimeInterface or parsable string) + * @param \DateTimeInterface|string $until End date (DateTimeInterface or parsable string) * @param bool $validateAvailability Whether to validate product availability for the timespan * @return $this * @throws InvalidDateRangeException * @throws NotEnoughAvailableInTimespanException */ - public function setDates(\DateTimeInterface $from, \DateTimeInterface $until, bool $validateAvailability = true): self + public function setDates(\DateTimeInterface|string $from, \DateTimeInterface|string $until, bool $validateAvailability = true): self { + // Parse string dates using Carbon + if (is_string($from)) { + $from = Carbon::parse($from); + } + if (is_string($until)) { + $until = Carbon::parse($until); + } + if ($from >= $until) { throw new InvalidDateRangeException(); } @@ -214,14 +222,19 @@ class Cart extends Model /** * Set the 'from' date for the cart. * - * @param \DateTimeInterface $from Start date + * @param \DateTimeInterface|string $from Start date (DateTimeInterface or parsable string) * @param bool $validateAvailability Whether to validate product availability for the timespan * @return $this * @throws InvalidDateRangeException * @throws NotEnoughAvailableInTimespanException */ - public function setFromDate(\DateTimeInterface $from, bool $validateAvailability = true): self + public function setFromDate(\DateTimeInterface|string $from, bool $validateAvailability = true): self { + // Parse string dates using Carbon + if (is_string($from)) { + $from = Carbon::parse($from); + } + if ($this->until_date && $from >= $this->until_date) { throw new InvalidDateRangeException(); } @@ -238,14 +251,19 @@ class Cart extends Model /** * Set the 'until' date for the cart. * - * @param \DateTimeInterface $until End date + * @param \DateTimeInterface|string $until End date (DateTimeInterface or parsable string) * @param bool $validateAvailability Whether to validate product availability for the timespan * @return $this * @throws InvalidDateRangeException * @throws NotEnoughAvailableInTimespanException */ - public function setUntilDate(\DateTimeInterface $until, bool $validateAvailability = true): self + public function setUntilDate(\DateTimeInterface|string $until, bool $validateAvailability = true): self { + // Parse string dates using Carbon + if (is_string($until)) { + $until = Carbon::parse($until); + } + if ($this->from_date && $this->from_date >= $until) { throw new InvalidDateRangeException(); } @@ -597,11 +615,11 @@ class Cart extends Model // Ensure prices are not null if ($pricePerDay === null) { - $debugInfo = ''; if ($cartable instanceof Product && $cartable->isPool()) { - $debugInfo = " (Pool product, currentQuantityInCart: {$currentQuantityInCart}, hasPrice: " . ($cartable->hasPrice() ? 'yes' : 'no') . ")"; + // For pool products, throw specific error when neither pool nor single items have prices + throw \Blax\Shop\Exceptions\HasNoPriceException::poolProductNoPriceAndNoSingleItemPrices($cartable->name); } - throw new \Exception("Product '{$cartable->name}' has no valid price.{$debugInfo}"); + throw new \Exception("Product '{$cartable->name}' has no valid price."); } // Calculate days if booking dates provided diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index 6702a9c..5738f63 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -376,15 +376,22 @@ class CartItem extends Model * NOTE: This method allows setting any dates, even if they're not available. * Use the is_ready_to_checkout attribute to check if the dates are valid. * - * @param \DateTimeInterface|null $from Start date - * @param \DateTimeInterface|null $until End date + * @param \DateTimeInterface|string|null $from Start date (DateTimeInterface or parsable string) + * @param \DateTimeInterface|string|null $until End date (DateTimeInterface or parsable string) * @return $this * @throws \Exception If dates are invalid */ public function updateDates( - \DateTimeInterface|null $from = null, - \DateTimeInterface|null $until = null + \DateTimeInterface|string|null $from = null, + \DateTimeInterface|string|null $until = null ): self { + // Parse string dates using Carbon + if (is_string($from)) { + $from = \Carbon\Carbon::parse($from); + } + if (is_string($until)) { + $until = \Carbon\Carbon::parse($until); + } if ($from >= $until && $until) { throw new \Exception("The 'from' date must be before the 'until' date."); } @@ -421,12 +428,17 @@ class CartItem extends Model /** * Set the 'from' date for this cart item. * - * @param \DateTimeInterface $from Start date + * @param \DateTimeInterface|string $from Start date (DateTimeInterface or parsable string) * @return $this * @throws InvalidDateRangeException */ - public function setFromDate(\DateTimeInterface $from): self + public function setFromDate(\DateTimeInterface|string $from): self { + // Parse string dates using Carbon + if (is_string($from)) { + $from = \Carbon\Carbon::parse($from); + } + if ($this->until && $from >= $this->until) { throw new InvalidDateRangeException(); } @@ -448,12 +460,17 @@ class CartItem extends Model /** * Set the 'until' date for this cart item. * - * @param \DateTimeInterface $until End date + * @param \DateTimeInterface|string $until End date (DateTimeInterface or parsable string) * @return $this * @throws InvalidDateRangeException */ - public function setUntilDate(\DateTimeInterface $until): self + public function setUntilDate(\DateTimeInterface|string $until): self { + // Parse string dates using Carbon + if (is_string($until)) { + $until = \Carbon\Carbon::parse($until); + } + if ($this->from && $this->from >= $until) { throw new InvalidDateRangeException(); } diff --git a/src/Models/Product.php b/src/Models/Product.php index e12c112..7f14f66 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -395,10 +395,12 @@ class Product extends Model implements Purchasable, Cartable if ($cart) { // Cart-aware: Use smarter pricing that considers which price tiers are used + // This returns null if no items are available (all sold out) return $this->getNextAvailablePoolPriceConsideringCart($cart, $sales_price); } - // No cart and no user: Get inherited price based on strategy (lowest/highest/average of ALL available items) + // No cart: Get inherited price from single items + // This returns null if no items are available OR if items exist but have no prices return $this->getInheritedPoolPrice($sales_price); } @@ -471,10 +473,13 @@ class Product extends Model implements Purchasable, Cartable }); if ($singleItemsWithPrices->isEmpty()) { - $errors[] = "Pool product has no pricing (direct or inherited)"; - if ($throwExceptions) { - throw HasNoPriceException::poolProductNoPriceAndNoSingleItemPrices($this->name); - } + // Pool has no direct price AND no single items with prices + // This is only an error if we're actually trying to use the price + // So we don't throw here - let the actual usage point handle it + $warnings[] = "Pool product has no pricing (direct or inherited). Price will be needed when adding to cart."; + } else { + // Pool has single items with prices - this is valid + $warnings[] = "Pool product uses inherited pricing from single items"; } } @@ -483,7 +488,7 @@ class Product extends Model implements Purchasable, Cartable return $this->validateDirectPricing($throwExceptions); } - // Pool with inherited pricing is valid + // Pool without direct pricing is valid as long as it has single items with prices return [ 'valid' => empty($errors), 'errors' => $errors, diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php index 67e7dde..4ac25df 100644 --- a/src/Traits/MayBePoolProduct.php +++ b/src/Traits/MayBePoolProduct.php @@ -266,6 +266,10 @@ trait MayBePoolProduct $singleItems = $this->singleProducts; if ($singleItems->isEmpty()) { + // No single items, fall back to pool's direct price if available + if ($this->hasPrice()) { + return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale()); + } return null; } @@ -286,6 +290,24 @@ trait MayBePoolProduct })->filter()->values(); if ($prices->isEmpty()) { + // Single items exist but either: + // 1. None are available (sold out) - return null + // 2. None have prices configured - fall back to pool's direct price + + // Check if any items are available but just missing prices + $hasAvailableItemsWithoutPrices = $singleItems->contains(function ($item) use ($from, $until) { + if ($from && $until) { + return $item->isAvailableForBooking($from, $until, 1); + } + return $item->getAvailableStock() > 0 || !$item->manage_stock; + }); + + // If items are available but have no prices, use pool's direct price as fallback + if ($hasAvailableItemsWithoutPrices && $this->hasPrice()) { + return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale()); + } + + // Items are sold out or pool has no fallback price return null; } diff --git a/tests/Feature/CartDateStringParsingTest.php b/tests/Feature/CartDateStringParsingTest.php new file mode 100644 index 0000000..cb34af5 --- /dev/null +++ b/tests/Feature/CartDateStringParsingTest.php @@ -0,0 +1,204 @@ +user = \Workbench\App\Models\User::factory()->create(); + + $this->cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + $this->bookingProduct = Product::factory()->create([ + 'name' => 'Hotel Room', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->bookingProduct->increaseStock(5); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->bookingProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 10000, // $100/day + 'currency' => 'USD', + 'is_default' => true, + ]); + } + + /** @test */ + public function cart_set_dates_accepts_string_dates() + { + $cart = $this->cart->setDates('2025-12-20', '2025-12-25', false); + + $this->assertNotNull($cart->from_date); + $this->assertNotNull($cart->until_date); + $this->assertEquals('2025-12-20', $cart->from_date->format('Y-m-d')); + $this->assertEquals('2025-12-25', $cart->until_date->format('Y-m-d')); + } + + /** @test */ + public function cart_set_dates_accepts_datetime_objects() + { + $from = Carbon::parse('2025-12-20'); + $until = Carbon::parse('2025-12-25'); + + $cart = $this->cart->setDates($from, $until, false); + + $this->assertEquals('2025-12-20', $cart->from_date->format('Y-m-d')); + $this->assertEquals('2025-12-25', $cart->until_date->format('Y-m-d')); + } + + /** @test */ + public function cart_set_from_date_accepts_string() + { + $cart = $this->cart->setFromDate('2025-12-20', false); + + $this->assertNotNull($cart->from_date); + $this->assertEquals('2025-12-20', $cart->from_date->format('Y-m-d')); + } + + /** @test */ + public function cart_set_until_date_accepts_string() + { + $this->cart->update(['from_date' => Carbon::parse('2025-12-20')]); + $cart = $this->cart->setUntilDate('2025-12-25', false); + + $this->assertNotNull($cart->until_date); + $this->assertEquals('2025-12-25', $cart->until_date->format('Y-m-d')); + } + + /** @test */ + public function cart_set_dates_parses_various_string_formats() + { + // Test different date string formats that Carbon can parse + $testCases = [ + ['2025-12-20', '2025-12-25'], + ['2025/12/20', '2025/12/25'], + ['20-12-2025', '25-12-2025'], + ['December 20, 2025', 'December 25, 2025'], + ]; + + foreach ($testCases as [$from, $until]) { + $cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + $cart = $cart->setDates($from, $until, false); + + $this->assertNotNull($cart->from_date, "Failed to parse: $from"); + $this->assertNotNull($cart->until_date, "Failed to parse: $until"); + } + } + + /** @test */ + public function cart_item_set_from_date_accepts_string() + { + $cartItem = $this->cart->addToCart( + $this->bookingProduct, + 1, + [], + Carbon::parse('2025-12-20'), + Carbon::parse('2025-12-25') + ); + + $cartItem = $cartItem->setFromDate('2025-12-21'); + + $this->assertEquals('2025-12-21', $cartItem->from->format('Y-m-d')); + } + + /** @test */ + public function cart_item_set_until_date_accepts_string() + { + $cartItem = $this->cart->addToCart( + $this->bookingProduct, + 1, + [], + Carbon::parse('2025-12-20'), + Carbon::parse('2025-12-25') + ); + + $cartItem = $cartItem->setUntilDate('2025-12-26'); + + $this->assertEquals('2025-12-26', $cartItem->until->format('Y-m-d')); + } + + /** @test */ + public function cart_item_update_dates_accepts_string_dates() + { + $cartItem = $this->cart->addToCart( + $this->bookingProduct, + 1, + [], + Carbon::parse('2025-12-20'), + Carbon::parse('2025-12-25') + ); + + $cartItem = $cartItem->updateDates('2025-12-21', '2025-12-27'); + + $this->assertEquals('2025-12-21', $cartItem->from->format('Y-m-d')); + $this->assertEquals('2025-12-27', $cartItem->until->format('Y-m-d')); + + // Verify price was recalculated for new date range (6 days instead of 5) + $expectedPrice = 10000 * 6; // $100/day * 6 days + $this->assertEquals($expectedPrice, $cartItem->price); + } + + /** @test */ + public function cart_item_update_dates_accepts_mixed_string_and_datetime() + { + $cartItem = $this->cart->addToCart( + $this->bookingProduct, + 1, + [], + Carbon::parse('2025-12-20'), + Carbon::parse('2025-12-25') + ); + + // String from, DateTime until + $cartItem = $cartItem->updateDates('2025-12-21', Carbon::parse('2025-12-27')); + + $this->assertEquals('2025-12-21', $cartItem->from->format('Y-m-d')); + $this->assertEquals('2025-12-27', $cartItem->until->format('Y-m-d')); + } + + /** @test */ + public function cart_item_date_parsing_works_with_now_relative_strings() + { + $cartItem = $this->cart->addToCart( + $this->bookingProduct, + 1, + [], + Carbon::parse('2025-12-20'), + Carbon::parse('2025-12-25') + ); + + // Test relative date strings + $cartItem = $cartItem->updateDates('now', '+5 days'); + + $this->assertNotNull($cartItem->from); + $this->assertNotNull($cartItem->until); + $this->assertTrue($cartItem->from < $cartItem->until); + } +} diff --git a/tests/Feature/PoolProductPricingFlexibilityTest.php b/tests/Feature/PoolProductPricingFlexibilityTest.php new file mode 100644 index 0000000..ecac95d --- /dev/null +++ b/tests/Feature/PoolProductPricingFlexibilityTest.php @@ -0,0 +1,336 @@ +user = \Workbench\App\Models\User::factory()->create(); + } + + /** @test */ + public function pool_without_direct_price_uses_single_item_prices() + { + $pool = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $spot1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // $50 + 'currency' => 'USD', + 'is_default' => true, + ]); + + $pool->attachSingleItems([$spot1->id]); + + // Pool should be able to use single item price + $price = $pool->getCurrentPrice(); + $this->assertEquals(5000, $price); + + // Should be able to add to cart without pool having direct price + $cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + $cartItem = $cart->addToCart($pool, 1); + $this->assertNotNull($cartItem); + $this->assertEquals(5000, $cartItem->price); + } + + /** @test */ + public function pool_validation_does_not_throw_when_single_items_have_prices() + { + $pool = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $spot1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $pool->attachSingleItems([$spot1->id]); + + // validatePricing should not throw when single items have prices + $result = $pool->validatePricing(throwExceptions: false); + + $this->assertTrue($result['valid']); + $this->assertEmpty($result['errors']); + $this->assertNotEmpty($result['warnings']); + $this->assertStringContainsString('inherited pricing', $result['warnings'][0]); + } + + /** @test */ + public function pool_validation_warns_when_no_prices_available_but_does_not_throw() + { + $pool = Product::factory()->create([ + 'name' => 'Pool Without Any Prices', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + $pool->attachSingleItems([$spot1->id]); + + // validatePricing should not throw, just return warnings + $result = $pool->validatePricing(throwExceptions: false); + + $this->assertTrue($result['valid']); // Changed: should still be valid + $this->assertEmpty($result['errors']); // Changed: no errors + $this->assertNotEmpty($result['warnings']); + $this->assertStringContainsString('Price will be needed when adding to cart', $result['warnings'][0]); + } + + /** @test */ + public function pool_throws_exception_only_when_adding_to_cart_without_any_prices() + { + $pool = Product::factory()->create([ + 'name' => 'Pool Without Any Prices', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + $pool->attachSingleItems([$spot1->id]); + + $cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + // Exception should only be thrown when trying to add to cart + $this->expectException(HasNoPriceException::class); + $this->expectExceptionMessage('Cannot add pool product'); + $this->expectExceptionMessage('No pricing available'); + + $cart->addToCart($pool, 1); + } + + /** @test */ + public function pool_with_direct_price_used_as_fallback_when_single_items_have_no_prices() + { + $pool = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + // Single item has NO price + // Pool has direct price as fallback + ProductPrice::factory()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 4000, // $40 - fallback pool price + 'currency' => 'USD', + 'is_default' => true, + ]); + + $pool->attachSingleItems([$spot1->id]); + + // Pool should use its own direct price as fallback when single items have no prices + $price = $pool->getCurrentPrice(); + $this->assertEquals(4000, $price); + } + + /** @test */ + public function pool_prefers_single_item_prices_over_direct_price() + { + $pool = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + // Single item has price + ProductPrice::factory()->create([ + 'purchasable_id' => $spot1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, // $50 + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Pool also has direct price, but single item price should be preferred + ProductPrice::factory()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 4000, // $40 - pool price (should be ignored) + 'currency' => 'USD', + 'is_default' => true, + ]); + + $pool->attachSingleItems([$spot1->id]); + + // Pool should prefer single item price over its own direct price + $price = $pool->getCurrentPrice(); + $this->assertEquals(5000, $price); + } + + /** @test */ + public function pool_can_be_created_without_price_if_single_items_will_have_prices() + { + // This test verifies that pools can exist in a "not fully configured" state + // as long as they get prices before being added to cart + + $pool = Product::factory()->create([ + 'name' => 'Future Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + $pool->attachSingleItems([$spot1->id]); + + // At this point, neither pool nor single items have prices + // This should be allowed - pool can exist without prices + + $this->assertNotNull($pool); + $this->assertCount(1, $pool->singleProducts); + + // Now add price to single item + ProductPrice::factory()->create([ + 'purchasable_id' => $spot1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Now pool should be ready to use + $cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + $cartItem = $cart->addToCart($pool, 1); + $this->assertNotNull($cartItem); + $this->assertEquals(5000, $cartItem->price); + } + + /** @test */ + public function pool_uses_pricing_strategy_with_multiple_single_item_prices() + { + $pool = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + $spot2 = Product::factory()->create([ + 'name' => 'Spot 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot2->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $spot1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 3000, // $30 + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $spot2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 7000, // $70 + 'currency' => 'USD', + 'is_default' => true, + ]); + + $pool->attachSingleItems([$spot1->id, $spot2->id]); + + // By default, should use LOWEST pricing strategy + $price = $pool->getCurrentPrice(); + $this->assertEquals(3000, $price); + + // Change to HIGHEST + $pool->setPoolPricingStrategy('highest'); + $price = $pool->getCurrentPrice(); + $this->assertEquals(7000, $price); + + // Change to AVERAGE + $pool->setPoolPricingStrategy('average'); + $price = $pool->getCurrentPrice(); + $this->assertEquals(5000, $price); // (3000 + 7000) / 2 + } +} diff --git a/tests/Feature/ProductPricingValidationTest.php b/tests/Feature/ProductPricingValidationTest.php index 295049d..b4b8520 100644 --- a/tests/Feature/ProductPricingValidationTest.php +++ b/tests/Feature/ProductPricingValidationTest.php @@ -207,8 +207,8 @@ class ProductPricingValidationTest extends TestCase ]); $this->expectException(HasNoPriceException::class); - $this->expectExceptionMessage('Pool product'); - $this->expectExceptionMessage('has no pricing configured'); + $this->expectExceptionMessage('Cannot add pool product'); + $this->expectExceptionMessage('No pricing available'); Cart::add($pool, 1); }