diff --git a/src/Models/Cart.php b/src/Models/Cart.php index ffaa349..25fb475 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -8,6 +8,7 @@ use Blax\Shop\Enums\ProductType; use Blax\Shop\Exceptions\InvalidDateRangeException; use Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException; use Blax\Shop\Services\CartService; +use Blax\Shop\Traits\HasBookingPriceCalculation; use Blax\Workkit\Traits\HasExpiration; use Carbon\Carbon; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -18,7 +19,7 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; class Cart extends Model { - use HasUuids, HasExpiration, HasFactory; + use HasUuids, HasExpiration, HasFactory, HasBookingPriceCalculation; protected $fillable = [ 'session_id', @@ -589,7 +590,7 @@ class Cart extends Model $currentPrice = $cartable->getCurrentPrice(); } if ($from && $until) { - $days = max(1, $from->diff($until)->days); + $days = $this->calculateBookingDays($from, $until); $currentPrice *= $days; } @@ -629,7 +630,7 @@ class Cart extends Model // Calculate days if booking dates provided $days = 1; if ($from && $until) { - $days = max(1, $from->diff($until)->days); + $days = $this->calculateBookingDays($from, $until); } // Calculate price per unit for the entire period @@ -929,10 +930,11 @@ class Cart extends Model // Add description with booking dates if available $description = null; if ($item->from && $item->until) { - $days = max(1, $item->from->diffInDays($item->until)); - $fromFormatted = $item->from->format('M j, Y'); - $untilFormatted = $item->until->format('M j, Y'); - $description = "Period: {$fromFormatted} to {$untilFormatted} ({$days} day" . ($days > 1 ? 's' : '') . ")"; + $days = $this->calculateBookingDays($item->from, $item->until); + $fromFormatted = $item->from->format('M j, Y H:i'); + $untilFormatted = $item->until->format('M j, Y H:i'); + $daysText = number_format($days, 2) . ' day' . ($days != 1 ? 's' : ''); + $description = "Period: {$fromFormatted} to {$untilFormatted} ({$daysText})"; } if ($description) { diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index 669f8e5..2eac0d4 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -3,6 +3,7 @@ namespace Blax\Shop\Models; use Blax\Shop\Exceptions\InvalidDateRangeException; +use Blax\Shop\Traits\HasBookingPriceCalculation; use Blax\Workkit\Traits\HasMeta; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class CartItem extends Model { - use HasUuids, HasMeta; + use HasUuids, HasMeta, HasBookingPriceCalculation; protected $fillable = [ 'cart_id', @@ -401,8 +402,8 @@ class CartItem extends Model throw new \Exception("Cannot update dates for non-product items."); } - // Calculate days - $days = max(1, $from->diff($until)->days); + // Calculate days using per-minute precision + $days = $this->calculateBookingDays($from, $until); // Get current price per day $pricePerDay = $product->getCurrentPrice(); diff --git a/src/Services/CartService.php b/src/Services/CartService.php index bc301d9..636e9c8 100644 --- a/src/Services/CartService.php +++ b/src/Services/CartService.php @@ -12,12 +12,14 @@ use Blax\Shop\Exceptions\InvalidBookingConfigurationException; use Blax\Shop\Exceptions\InvalidPoolConfigurationException; use Blax\Shop\Exceptions\NotPurchasable; use Blax\Shop\Models\Product; +use Blax\Shop\Traits\HasBookingPriceCalculation; use Illuminate\Database\Eloquent\Model; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Facades\Auth; class CartService { + use HasBookingPriceCalculation; /** * Session key for storing the current cart ID */ @@ -535,7 +537,7 @@ class CartService // Calculate price based on days for booking products if ($product instanceof Product && ($product->isBooking() || $product->isPool())) { - $days = $from->diff($until)->days; + $days = $this->calculateBookingDays($from, $until); $pricePerUnit = $pricePerDay * $days; // Price for one unit for the entire period $totalPrice = $pricePerUnit * $quantity; // Total for all units } else { diff --git a/src/Traits/HasBookingPriceCalculation.php b/src/Traits/HasBookingPriceCalculation.php new file mode 100644 index 0000000..5c7277a --- /dev/null +++ b/src/Traits/HasBookingPriceCalculation.php @@ -0,0 +1,62 @@ +diffInMinutes($until); + + // Convert to days (1 day = 1440 minutes) + $days = $totalMinutes / 1440; + + // Round to 10 decimal places to avoid floating point errors + // while maintaining precision for fractional days + $days = round($days, 10); + + // Return at least a minimum value if dates are the same or very close + return max(0.000694, $days); // 0.000694 ≈ 1 minute in days + } + + /** + * Calculate the price for a booking based on exact duration. + * + * @param float $pricePerDay Price per day (24 hours) + * @param \DateTimeInterface $from Start date/time + * @param \DateTimeInterface $until End date/time + * @return float Calculated price for the duration + */ + protected function calculateBookingPrice( + float $pricePerDay, + \DateTimeInterface $from, + \DateTimeInterface $until + ): float { + $days = $this->calculateBookingDays($from, $until); + return $pricePerDay * $days; + } +} diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php index 4ac25df..d518682 100644 --- a/src/Traits/MayBePoolProduct.php +++ b/src/Traits/MayBePoolProduct.php @@ -11,6 +11,7 @@ use Blax\Shop\Exceptions\InvalidPoolConfigurationException; trait MayBePoolProduct { + use HasBookingPriceCalculation; /** * Check if this is a pool product */ @@ -702,7 +703,7 @@ trait MayBePoolProduct // Calculate days for price normalization $days = 1; if ($from && $until) { - $days = max(1, $from->diff($until)->days); + $days = $this->calculateBookingDays($from, $until); } // Build usage map: price => quantity used diff --git a/tests/Feature/BookingPerMinutePricingTest.php b/tests/Feature/BookingPerMinutePricingTest.php new file mode 100644 index 0000000..9cb370d --- /dev/null +++ b/tests/Feature/BookingPerMinutePricingTest.php @@ -0,0 +1,497 @@ +user = User::factory()->create(); + $this->actingAs($this->user); + + // Create a booking product + $this->bookingProduct = Product::factory()->create([ + 'name' => 'Conference Room', + 'slug' => 'conference-room', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'stock_quantity' => 0, + ]); + + // Initialize stock + $this->bookingProduct->increaseStock(10); + + // Create a price: $100.00 per day (24 hours) + $this->price = ProductPrice::factory()->create([ + 'purchasable_id' => $this->bookingProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 100, // $100.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + } + + /** @test */ + public function it_calculates_price_for_exact_24_hours() + { + $from = Carbon::now()->addDays(5)->setTime(9, 0, 0); + $until = Carbon::now()->addDays(6)->setTime(9, 0, 0); // Exactly 24 hours = 1 day + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + // Expecting exactly 100.00 for 1 day + $this->assertEquals('100.00', $cartItem->price); + $this->assertEquals(100.00, $cartItem->subtotal); + } + + /** @test */ + public function it_calculates_price_for_12_hours_as_half_day() + { + $from = Carbon::now()->addDays(5)->setTime(9, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(21, 0, 0); // 12 hours = 0.5 days + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + // Expecting 50.00 for 0.5 days (12 hours) + $this->assertEquals('50.00', $cartItem->price); + $this->assertEquals(50.00, $cartItem->subtotal); + } + + /** @test */ + public function it_calculates_price_for_36_hours() + { + $from = Carbon::now()->addDays(5)->setTime(9, 0, 0); + $until = Carbon::now()->addDays(6)->setTime(21, 0, 0); // 36 hours = 1.5 days + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + // Expecting 150.00 for 1.5 days (36 hours) + $this->assertEquals('150.00', $cartItem->price); + $this->assertEquals(150.00, $cartItem->subtotal); + } + + /** @test */ + public function it_calculates_price_for_6_hours() + { + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(16, 0, 0); // 6 hours = 0.25 days + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + // Expecting 25.00 for 0.25 days (6 hours) + $this->assertEquals('25.00', $cartItem->price); + $this->assertEquals(25.00, $cartItem->subtotal); + } + + /** @test */ + public function it_calculates_price_for_90_minutes() + { + $from = Carbon::now()->addDays(5)->setTime(14, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(15, 30, 0); // 90 minutes = 1.5 hours = 0.0625 days + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + // 90 minutes = 1.5 hours = 0.0625 days + // Price: 100.00 * 0.0625 = 6.25 + $this->assertEquals('6.25', $cartItem->price); + $this->assertEquals(6.25, $cartItem->subtotal); + } + + /** @test */ + public function it_calculates_price_for_2_days_and_3_hours() + { + $from = Carbon::now()->addDays(5)->setTime(9, 0, 0); + $until = Carbon::now()->addDays(7)->setTime(12, 0, 0); // 2 days + 3 hours = 51 hours = 2.125 days + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + // 51 hours = 2.125 days + // Price: 100.00 * 2.125 = 212.50 + $this->assertEquals('212.50', $cartItem->price); + $this->assertEquals(212.50, $cartItem->subtotal); + } + + /** @test */ + public function it_calculates_price_with_quantity_for_fractional_days() + { + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(22, 0, 0); // 12 hours = 0.5 days + + $cartItem = Cart::addBooking($this->bookingProduct, 3, $from, $until); + + // 0.5 days * 100.00 = 50.00 per unit + // 3 units * 50.00 = 150.00 total + $this->assertEquals('50.00', $cartItem->price); // price per unit + $this->assertEquals(150.00, $cartItem->subtotal); // total for 3 units + } + + /** @test */ + public function it_recalculates_price_when_dates_are_updated() + { + $from = Carbon::now()->addDays(5)->setTime(9, 0, 0); + $until = Carbon::now()->addDays(6)->setTime(9, 0, 0); // 24 hours = 1 day + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + // Initial price for 1 day + $this->assertEquals('100.00', $cartItem->price); + + // Update to 18 hours (0.75 days) + $newUntil = Carbon::now()->addDays(6)->setTime(3, 0, 0); + $cartItem->updateDates($from, $newUntil); + + // Price should now be 75.00 for 0.75 days + $this->assertEquals(75.00, $cartItem->fresh()->price); + $this->assertEquals(75.00, $cartItem->fresh()->subtotal); + } + + /** @test */ + public function it_calculates_price_for_45_minutes() + { + $from = Carbon::now()->addDays(5)->setTime(14, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(14, 45, 0); // 45 minutes = 0.03125 days + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + // 45 minutes = 0.75 hours = 0.03125 days + // Price: 100.00 * 0.03125 = 3.125 + $this->assertEquals(3.13, round($cartItem->price, 2)); // Rounded due to decimal precision + } + + /** @test */ + public function it_purchases_booking_with_per_minute_pricing() + { + $from = Carbon::now()->addDays(5)->setTime(14, 0, 0); + $until = Carbon::now()->addDays(6)->setTime(2, 0, 0); // 12 hours = 0.5 days + + $purchase = $this->user->purchase( + $this->price, + 2, + null, + $from, + $until + ); + + $this->assertNotNull($purchase); + $this->assertTrue($purchase->isBooking()); + + // Stock should be decreased + $this->bookingProduct->refresh(); + $this->assertEquals(8, $this->bookingProduct->getAvailableStock()); + } + + /** @test */ + public function it_updates_cart_item_from_date_recalculates_per_minute_price() + { + $from = Carbon::now()->addDays(5)->setTime(12, 0, 0); + $until = Carbon::now()->addDays(6)->setTime(12, 0, 0); // 24 hours + + $cart = \Blax\Shop\Models\Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + $cartItem = $cart->addToCart($this->bookingProduct, 1, [], $from, $until); + + // Initial: 1 day = 100.00 + $this->assertEquals('100.00', $cartItem->price); + + // Update from date to make it 30 hours (1.25 days) + $newFrom = Carbon::now()->addDays(5)->setTime(6, 0, 0); + $cartItem->setFromDate($newFrom); + + // Price should be 125.00 for 1.25 days + $this->assertEquals(125.00, $cartItem->fresh()->price); + } + + /** @test */ + public function it_updates_cart_item_until_date_recalculates_per_minute_price() + { + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(22, 0, 0); // 12 hours + + $cart = \Blax\Shop\Models\Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + $cartItem = $cart->addToCart($this->bookingProduct, 1, [], $from, $until); + + // Initial: 0.5 days = 50.00 + $this->assertEquals('50.00', $cartItem->price); + + // Update until date to make it 18 hours (0.75 days) + $newUntil = Carbon::now()->addDays(6)->setTime(4, 0, 0); + $cartItem->setUntilDate($newUntil); + + // Price should be 75.00 for 0.75 days + $this->assertEquals(75.00, $cartItem->fresh()->price); + } + + /** @test */ + public function it_calculates_price_for_weekend_booking_friday_to_monday() + { + // Friday 6pm to Monday 10am = 64 hours = 2.666... days + $from = Carbon::now()->addDays(5)->setTime(18, 0, 0); // Friday 6pm + $until = Carbon::now()->addDays(8)->setTime(10, 0, 0); // Monday 10am + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + // 64 hours = 2.6667 days (rounded) + // Price: 100.00 * 2.6667 = 266.67 + $expectedPrice = round(100.00 * (64 / 24), 2); + $this->assertEquals($expectedPrice, $cartItem->price); + } + + /** @test */ + public function it_handles_multiple_bookings_with_different_durations() + { + $cart = \Blax\Shop\Models\Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + // Booking 1: 12 hours + $from1 = Carbon::now()->addDays(5)->setTime(9, 0, 0); + $until1 = Carbon::now()->addDays(5)->setTime(21, 0, 0); + $item1 = $cart->addToCart($this->bookingProduct, 1, [], $from1, $until1); + + // Booking 2: 6 hours + $from2 = Carbon::now()->addDays(10)->setTime(10, 0, 0); + $until2 = Carbon::now()->addDays(10)->setTime(16, 0, 0); + $item2 = $cart->addToCart($this->bookingProduct, 1, [], $from2, $until2); + + $this->assertEquals('50.00', $item1->price); // 12 hours = 0.5 days + $this->assertEquals('25.00', $item2->price); // 6 hours = 0.25 days + + // Total cart should be 75.00 + $this->assertEquals(75.00, $cart->getTotal()); + } + + /** @test */ + public function it_calculates_precise_price_for_irregular_time_spans() + { + // Test various odd time spans + $testCases = [ + ['hours' => 1, 'expectedDays' => 1 / 24, 'expectedPrice' => 4.17], // 1 hour + ['hours' => 3, 'expectedDays' => 3 / 24, 'expectedPrice' => 12.50], // 3 hours + ['hours' => 7, 'expectedDays' => 7 / 24, 'expectedPrice' => 29.17], // 7 hours + ['hours' => 13, 'expectedDays' => 13 / 24, 'expectedPrice' => 54.17], // 13 hours + ['hours' => 25, 'expectedDays' => 25 / 24, 'expectedPrice' => 104.17], // 25 hours + ]; + + foreach ($testCases as $testCase) { + $from = Carbon::now()->addDays(5)->setTime(12, 0, 0); + $until = $from->copy()->addHours($testCase['hours']); + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + $this->assertEquals( + $testCase['expectedPrice'], + $cartItem->price, + "Failed for {$testCase['hours']} hours" + ); + + // Clean up for next test + $cartItem->delete(); + } + } + + /** @test */ + public function it_handles_booking_removal_and_readdition() + { + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(16, 0, 0); // 6 hours + + $cartItem = Cart::addBooking($this->bookingProduct, 2, $from, $until); + + // Verify per-minute pricing is correct + $this->assertEquals('25.00', $cartItem->price); + $this->assertEquals(2, $cartItem->quantity); + $this->assertEquals(50.00, $cartItem->subtotal); + + // Remove item + $cartItemId = $cartItem->id; + $cartItem->delete(); + + // Verify deletion + $this->assertNull(\Blax\Shop\Models\CartItem::find($cartItemId)); + + // Re-add with different quantity - price calculation should be consistent + $cartItem2 = Cart::addBooking($this->bookingProduct, 3, $from, $until); + $this->assertEquals('25.00', $cartItem2->price); + $this->assertEquals(3, $cartItem2->quantity); + $this->assertEquals(75.00, $cartItem2->subtotal); + } + + /** @test */ + public function it_calculates_price_for_half_hour_increments() + { + $testCases = [ + ['minutes' => 30, 'expectedPrice' => '2.08'], // 0.5 hours + ['minutes' => 90, 'expectedPrice' => '6.25'], // 1.5 hours + ['minutes' => 150, 'expectedPrice' => '10.42'], // 2.5 hours + ['minutes' => 210, 'expectedPrice' => '14.58'], // 3.5 hours + ['minutes' => 270, 'expectedPrice' => '18.75'], // 4.5 hours + ]; + + foreach ($testCases as $testCase) { + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = $from->copy()->addMinutes($testCase['minutes']); + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + $this->assertEquals( + $testCase['expectedPrice'], + $cartItem->price, + "Failed for {$testCase['minutes']} minutes" + ); + + $cartItem->delete(); + } + } + + /** @test */ + public function it_handles_exact_hour_boundaries() + { + // Test exact hour boundaries from 1-10 hours + for ($hours = 1; $hours <= 10; $hours++) { + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = $from->copy()->addHours($hours); + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + $expectedDays = $hours / 24; + $expectedPrice = number_format(100 * $expectedDays, 2, '.', ''); + + $this->assertEquals( + $expectedPrice, + $cartItem->price, + "Failed for {$hours} hours" + ); + + $cartItem->delete(); + } + } + + /** @test */ + public function it_maintains_price_consistency_across_cart_operations() + { + $from = Carbon::now()->addDays(5)->setTime(14, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(20, 0, 0); // 6 hours + + // Add to cart + $cart = $this->user->currentCart(); + $cartItem = $cart->addToCart($this->bookingProduct, 1, [], $from, $until); + $initialPrice = $cartItem->price; + + // Refresh and check price hasn't changed + $cartItem->refresh(); + $this->assertEquals($initialPrice, $cartItem->price); + + // Get cart total + $total = $cart->getTotal(); + $this->assertEquals(25.00, $total); + + // Add more quantity + $cart->addToCart($this->bookingProduct, 1, [], $from, $until); + $cart->refresh(); + + // Total should double + $this->assertEquals(50.00, $cart->getTotal()); + } + + /** @test */ + public function it_handles_very_long_booking_periods() + { + // 7 days = 168 hours + $from = Carbon::now()->addDays(5)->setTime(12, 0, 0); + $until = Carbon::now()->addDays(12)->setTime(12, 0, 0); + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + // 7 days * $100 = $700.00 + $this->assertEquals('700.00', $cartItem->price); + $this->assertEquals(700.00, $cartItem->subtotal); + } + + /** @test */ + public function it_calculates_price_for_7_hour_30_minute_booking() + { + $from = Carbon::now()->addDays(5)->setTime(9, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(16, 30, 0); // 7.5 hours + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + // 7.5 hours = 0.3125 days, $100 * 0.3125 = $31.25 + $this->assertEquals('31.25', $cartItem->price); + } + + /** @test */ + public function it_handles_booking_crossing_midnight() + { + // 11 PM to 3 AM = 4 hours + $from = Carbon::now()->addDays(5)->setTime(23, 0, 0); + $until = Carbon::now()->addDays(6)->setTime(3, 0, 0); + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + // 4 hours = 0.166667 days, $100 * 0.166667 = $16.67 + $this->assertEquals('16.67', $cartItem->price); + } + + /** @test */ + public function it_validates_minimum_price_for_very_short_bookings() + { + // 2 minutes + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(10, 2, 0); + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + // 2 minutes = 0.001389 days, $100 * 0.001389 = $0.1389, rounds to $0.14 + $this->assertEquals('0.14', $cartItem->price); + + // Should still be less than 1 dollar + $this->assertLessThan(1.0, (float)$cartItem->price); + } + + /** @test */ + public function it_handles_15_minute_interval_bookings() + { + $intervals = [15, 30, 45, 60, 75, 90, 105, 120]; + + foreach ($intervals as $minutes) { + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = $from->copy()->addMinutes($minutes); + + $cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until); + + $expectedDays = $minutes / 1440; + $expectedPrice = number_format(100 * $expectedDays, 2, '.', ''); + + $this->assertEquals( + $expectedPrice, + $cartItem->price, + "Failed for {$minutes} minutes" + ); + + $cartItem->delete(); + } + } +} diff --git a/tests/Feature/CartAddToCartPoolPricingTest.php b/tests/Feature/CartAddToCartPoolPricingTest.php index 72b47aa..2b097a2 100644 --- a/tests/Feature/CartAddToCartPoolPricingTest.php +++ b/tests/Feature/CartAddToCartPoolPricingTest.php @@ -473,19 +473,19 @@ class CartAddToCartPoolPricingTest extends TestCase ProductPrice::factory()->create([ 'purchasable_id' => $this->poolProduct->id, 'purchasable_type' => Product::class, - 'unit_amount' => 3000, + 'unit_amount' => 30, 'currency' => 'USD', 'is_default' => true, ]); - // Same day booking (0 days diff) + // Same day booking (4 hours) $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 + // 4 hours = 0.1667 days, 30 * 0.1667 = 5.00 (rounded to 2 decimals) + $this->assertEquals('5.00', $cartItem->price); } /** @test */ @@ -1199,9 +1199,10 @@ class CartAddToCartPoolPricingTest extends TestCase $until ); - $this->assertEquals( + $this->assertEqualsWithDelta( (2000 * 2 * 5) + (5000 * 1 * 5), - $cart->getTotal() + $cart->getTotal(), + 0.01 // Allow 1 cent tolerance for floating point errors ); $this->assertEquals( 5000, @@ -1216,9 +1217,10 @@ class CartAddToCartPoolPricingTest extends TestCase $until ); - $this->assertEquals( + $this->assertEqualsWithDelta( (2000 * 2 * 5) + (5000 * 2 * 5) + (8000 * 2 * 5), - $cart->getTotal() + $cart->getTotal(), + 0.01 ); $this->assertNull($pool->getCurrentPrice()); @@ -1257,24 +1259,27 @@ class CartAddToCartPoolPricingTest extends TestCase $until ); - $this->assertEquals( + $this->assertEqualsWithDelta( (2000 * 2 * 5) + (5000 * 1 * 5) + (8000 * 2 * 5), - $cart->getTotal() + $cart->getTotal(), + 0.01 ); $cart->removeFromCart($pool, 1); - $this->assertEquals( + $this->assertEqualsWithDelta( (2000 * 2 * 5) + (5000 * 1 * 5) + (8000 * 1 * 5), - $cart->getTotal() + $cart->getTotal(), + 0.01 ); $this->assertEquals(8000, $pool->getCurrentPrice()); $cart->removeFromCart($pool, 1); - $this->assertEquals( + $this->assertEqualsWithDelta( (2000 * 2 * 5) + (5000 * 1 * 5), - $cart->getTotal() + $cart->getTotal(), + 0.01 ); // Get cart item with price 2000 @@ -1284,9 +1289,10 @@ class CartAddToCartPoolPricingTest extends TestCase $cart->removeFromCart($cartItem, 1); - $this->assertEquals( + $this->assertEqualsWithDelta( (2000 * 1 * 5) + (5000 * 1 * 5), - $cart->getTotal() + $cart->getTotal(), + 0.01 ); $this->assertEquals( 2000, @@ -1301,9 +1307,10 @@ class CartAddToCartPoolPricingTest extends TestCase $until ); - $this->assertEquals( + $this->assertEqualsWithDelta( (2000 * 2 * 5) + (5000 * 1 * 5), - $cart->getTotal() + $cart->getTotal(), + 0.01 ); // Get cart item with price 2000 @@ -1313,9 +1320,10 @@ class CartAddToCartPoolPricingTest extends TestCase $cart->removeFromCart($cartItem, 2); - $this->assertEquals( + $this->assertEqualsWithDelta( (5000 * 1 * 5), - $cart->getTotal() + $cart->getTotal(), + 0.01 ); $this->assertEquals(2000, $pool->getCurrentPrice()); @@ -1328,9 +1336,10 @@ class CartAddToCartPoolPricingTest extends TestCase $until ); - $this->assertEquals( + $this->assertEqualsWithDelta( (2000 * 2 * 5) + (5000 * 1 * 5) + (8000 * 2 * 5), - $cart->getTotal() + $cart->getTotal(), + 0.01 ); } } diff --git a/tests/Feature/PoolPerMinutePricingTest.php b/tests/Feature/PoolPerMinutePricingTest.php new file mode 100644 index 0000000..1f49714 --- /dev/null +++ b/tests/Feature/PoolPerMinutePricingTest.php @@ -0,0 +1,522 @@ +user = User::factory()->create(); + $this->actingAs($this->user); + + // Create pool product + $this->poolProduct = Product::factory()->create([ + 'name' => 'Parking Pool', + 'slug' => 'parking-pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create single items (parking spots) + $this->singleItem1 = Product::factory()->create([ + 'name' => 'Parking Spot A', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $this->singleItem1->increaseStock(1); + + $this->singleItem2 = Product::factory()->create([ + 'name' => 'Parking Spot B', + '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, + ]); + + // Set prices on single items: $50 and $30 per day + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 50, // $50.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + ProductPrice::factory()->create([ + 'purchasable_id' => $this->singleItem2->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 30, // $30.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Set pool to use LOWEST pricing strategy (default) + $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); + } + + /** @test */ + public function it_calculates_pool_price_for_12_hours() + { + $from = Carbon::now()->addDays(5)->setTime(8, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(20, 0, 0); // 12 hours = 0.5 days + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // Lowest price is $30, for 0.5 days = $15.00 + $this->assertEquals(15.00, $cartItem->price); + $this->assertEquals(15.00, $cartItem->subtotal); + } + + /** @test */ + public function it_calculates_pool_price_for_36_hours() + { + $from = Carbon::now()->addDays(5)->setTime(9, 0, 0); + $until = Carbon::now()->addDays(6)->setTime(21, 0, 0); // 36 hours = 1.5 days + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // Lowest price is $30, for 1.5 days = $45.00 + $this->assertEquals(45.00, $cartItem->price); + $this->assertEquals(45.00, $cartItem->subtotal); + } + + /** @test */ + public function it_calculates_pool_price_for_6_hours() + { + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(16, 0, 0); // 6 hours = 0.25 days + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // Lowest price is $30, for 0.25 days = $7.50 + $this->assertEquals(7.50, $cartItem->price); + $this->assertEquals(7.50, $cartItem->subtotal); + } + + /** @test */ + public function it_calculates_pool_price_for_90_minutes() + { + $from = Carbon::now()->addDays(5)->setTime(14, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(15, 30, 0); // 90 minutes = 1.5 hours = 0.0625 days + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // Lowest price is $30, for 0.0625 days = $1.875 (rounds to 1.88) + $this->assertEquals(1.88, round($cartItem->price, 2)); + } + + /** @test */ + public function it_uses_direct_pool_price_for_fractional_days() + { + // Set direct price on pool instead of using inherited pricing + ProductPrice::factory()->create([ + 'purchasable_id' => $this->poolProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 20, // $20.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(22, 0, 0); // 12 hours = 0.5 days + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // Direct pool price is $20, for 0.5 days = $10.00 + $this->assertEquals(10.00, $cartItem->price); + $this->assertEquals(10.00, $cartItem->subtotal); + } + + /** @test */ + public function it_calculates_pool_price_with_quantity_for_fractional_days() + { + $from = Carbon::now()->addDays(5)->setTime(9, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(15, 0, 0); // 6 hours = 0.25 days + + $cartItem = Cart::addBooking($this->poolProduct, 2, $from, $until); + + // Lowest price is $30, for 0.25 days = $7.50 per unit + // 2 units * $7.50 = $15.00 total + $this->assertEquals(7.50, $cartItem->price); // price per unit + $this->assertEquals(15.00, $cartItem->subtotal); // total for 2 units + } + + /** @test */ + public function it_uses_highest_pricing_strategy_for_fractional_days() + { + // Change to HIGHEST pricing strategy + $this->poolProduct->setPricingStrategy(PricingStrategy::HIGHEST); + + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(22, 0, 0); // 12 hours = 0.5 days + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // Highest price is $50, for 0.5 days = $25.00 + $this->assertEquals(25.00, $cartItem->price); + $this->assertEquals(25.00, $cartItem->subtotal); + } + + /** @test */ + public function it_uses_average_pricing_strategy_for_fractional_days() + { + // Change to AVERAGE pricing strategy + $this->poolProduct->setPricingStrategy(PricingStrategy::AVERAGE); + + $from = Carbon::now()->addDays(5)->setTime(8, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(20, 0, 0); // 12 hours = 0.5 days + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // Average price is ($50 + $30) / 2 = $40, for 0.5 days = $20.00 + $this->assertEquals(20.00, $cartItem->price); + $this->assertEquals(20.00, $cartItem->subtotal); + } + + /** @test */ + public function it_calculates_pool_price_for_multiple_fractional_bookings_in_cart() + { + $from1 = Carbon::now()->addDays(10)->setTime(10, 0, 0); + $until1 = Carbon::now()->addDays(10)->setTime(16, 0, 0); // 6 hours = 0.25 days + + $from2 = Carbon::now()->addDays(12)->setTime(14, 0, 0); + $until2 = Carbon::now()->addDays(12)->setTime(20, 0, 0); // 6 hours = 0.25 days + + $cart = \Blax\Shop\Models\Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + // Add two different fractional bookings + $cartItem1 = $cart->addToCart($this->poolProduct, 1, [], $from1, $until1); + $cartItem2 = $cart->addToCart($this->poolProduct, 1, [], $from2, $until2); + + // First booking uses lowest pricing: $30 * 0.25 = $7.50 + $this->assertEquals('7.50', $cartItem1->price); + // Second booking may use next available pricing tier + $this->assertGreaterThanOrEqual(7.50, (float)$cartItem2->price); + + // Total should be reasonable for two 6-hour bookings + $this->assertGreaterThan(15.00, $cart->getTotal()); + } + + /** @test */ + public function it_calculates_pool_price_for_very_short_durations() + { + // 30 minutes + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(10, 30, 0); + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // 30 minutes = 0.020833 days, $30 * 0.020833 = $0.62499, rounds to $0.62 + $this->assertEquals('0.62', $cartItem->price); + + // 15 minutes + $from2 = Carbon::now()->addDays(6)->setTime(14, 0, 0); + $until2 = Carbon::now()->addDays(6)->setTime(14, 15, 0); + + $cartItem2 = Cart::addBooking($this->poolProduct, 1, $from2, $until2); + + // 15 minutes = 0.010417 days, $30 * 0.010417 = $0.3125, rounds to $0.31 + $this->assertEquals('0.31', $cartItem2->price); + } + + /** @test */ + public function it_handles_multiple_pool_bookings_with_different_durations() + { + $cart = \Blax\Shop\Models\Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + // Booking 1: 12 hours + $from1 = Carbon::now()->addDays(5)->setTime(9, 0, 0); + $until1 = Carbon::now()->addDays(5)->setTime(21, 0, 0); + $item1 = $cart->addToCart($this->poolProduct, 1, [], $from1, $until1); + + // Booking 2: 6 hours (different dates, so spots available) + $from2 = Carbon::now()->addDays(10)->setTime(10, 0, 0); + $until2 = Carbon::now()->addDays(10)->setTime(16, 0, 0); + $item2 = $cart->addToCart($this->poolProduct, 1, [], $from2, $until2); + + // First booking: $30 * 0.5 = $15.00 + $this->assertEquals(15.00, $item1->price); + + // Second booking: $30 * 0.25 = $7.50 (different dates, so spots available) + $this->assertEquals(7.50, $item2->price); + } + + /** @test */ + public function it_calculates_pool_price_for_3_hours() + { + $from = Carbon::now()->addDays(5)->setTime(14, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(17, 0, 0); // 3 hours = 0.125 days + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // Lowest price is $30, for 0.125 days = $3.75 + $this->assertEquals(3.75, $cartItem->price); + } + + /** @test */ + public function it_calculates_pool_price_for_odd_duration_5_hours_30_minutes() + { + $from = Carbon::now()->addDays(5)->setTime(9, 30, 0); + $until = Carbon::now()->addDays(5)->setTime(15, 0, 0); // 5.5 hours = 0.229167 days + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // Lowest price is $30, for 5.5 hours (0.229167 days) = $6.875 (rounds to 6.88) + $expectedPrice = round(30.00 * (5.5 / 24), 2); + $this->assertEquals($expectedPrice, round($cartItem->price, 2)); + } + + /** @test */ + public function it_handles_pool_booking_over_multiple_days_with_hours() + { + // Monday 2pm to Wednesday 5pm = 51 hours = 2.125 days + $from = Carbon::now()->addDays(10)->setTime(14, 0, 0); + $until = Carbon::now()->addDays(12)->setTime(17, 0, 0); + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // Lowest price is $30, for 2.125 days = $63.75 + $expectedPrice = 30.00 * 2.125; + $this->assertEquals($expectedPrice, $cartItem->price); + } + + /** @test */ + public function it_prices_pool_correctly_when_both_spots_have_different_prices_for_fractional_time() + { + // Create a third spot with an even different price + $singleItem3 = Product::factory()->create([ + 'name' => 'Parking Spot C', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $singleItem3->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $singleItem3->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 40, // $40.00 per day + 'currency' => 'USD', + 'is_default' => true, + ]); + + $this->poolProduct->productRelations()->attach($singleItem3->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(16, 0, 0); // 6 hours = 0.25 days + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // Lowest price is still $30, for 0.25 days = $7.50 + $this->assertEquals(7.50, $cartItem->price); + } + + /** @test */ + public function it_calculates_price_for_exact_24_hours_pool() + { + $from = Carbon::now()->addDays(5)->setTime(9, 0, 0); + $until = Carbon::now()->addDays(6)->setTime(9, 0, 0); // Exactly 24 hours = 1 day + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // Lowest price is $30, for exactly 1 day = $30.00 + $this->assertEquals(30.00, $cartItem->price); + $this->assertEquals(30.00, $cartItem->subtotal); + } + + /** @test */ + public function it_updates_pool_cart_item_from_date_recalculates_per_minute_price() + { + $from = Carbon::now()->addDays(5)->setTime(12, 0, 0); + $until = Carbon::now()->addDays(6)->setTime(12, 0, 0); // 24 hours + + $cart = \Blax\Shop\Models\Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + $cartItem = $cart->addToCart($this->poolProduct, 1, [], $from, $until); + + // Initial: 1 day = $30.00 + $this->assertEquals(30.00, $cartItem->price); + + // Update from date to make it 30 hours (1.25 days) + $newFrom = Carbon::now()->addDays(5)->setTime(6, 0, 0); + $cartItem->setFromDate($newFrom); + + // Price should be $30 * 1.25 = $37.50 + $this->assertEquals(37.50, $cartItem->fresh()->price); + } + + /** @test */ + public function it_handles_booking_spanning_exactly_two_days() + { + $from = Carbon::now()->addDays(5)->setTime(0, 0, 0); + $until = Carbon::now()->addDays(7)->setTime(0, 0, 0); // Exactly 48 hours + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // 48 hours = 2 days, $30 * 2 = $60.00 + $this->assertEquals('60.00', $cartItem->price); + $this->assertEquals(60.00, $cartItem->subtotal); + } + + /** @test */ + public function it_calculates_price_for_business_hours_booking() + { + // 9 AM to 5 PM = 8 hours + $from = Carbon::now()->addDays(5)->setTime(9, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(17, 0, 0); + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // 8 hours = 0.333333 days, $30 * 0.333333 = $10.00 + $this->assertEquals('10.00', $cartItem->price); + } + + /** @test */ + public function it_handles_overnight_booking() + { + // 10 PM to 6 AM next day = 8 hours + $from = Carbon::now()->addDays(5)->setTime(22, 0, 0); + $until = Carbon::now()->addDays(6)->setTime(6, 0, 0); + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // 8 hours = 0.333333 days, $30 * 0.333333 = $10.00 + $this->assertEquals('10.00', $cartItem->price); + } + + /** @test */ + public function it_calculates_price_with_minutes_precision() + { + // 2 hours and 45 minutes + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(12, 45, 0); + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // 165 minutes = 0.114583 days, $30 * 0.114583 = $3.4375, rounds to $3.44 + $this->assertEquals('3.44', $cartItem->price); + } + + /** @test */ + public function it_maintains_precision_for_multiple_quantity() + { + // 6 hours + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(16, 0, 0); + + // Pool has 2 items, so max quantity is 2 + $cartItem = Cart::addBooking($this->poolProduct, 2, $from, $until); + + // 6 hours = 0.25 days, price per unit varies by pool pricing + $this->assertEquals(2, $cartItem->quantity); + // Subtotal should be reasonable for 6 hours with 2 items + $this->assertGreaterThan(10.00, $cartItem->subtotal); + } + + /** @test */ + public function it_handles_weekend_hourly_booking() + { + // Friday 6 PM to Sunday 6 PM = 48 hours exactly + $from = Carbon::now()->next(Carbon::FRIDAY)->setTime(18, 0, 0); + $until = Carbon::now()->next(Carbon::FRIDAY)->addDays(2)->setTime(18, 0, 0); + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // 48 hours = 2 days, $30 * 2 = $60.00 + $this->assertEquals('60.00', $cartItem->price); + } + + /** @test */ + public function it_calculates_different_pricing_strategies_for_fractional_time() + { + // Test LOWEST (already set in setUp) + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(13, 0, 0); // 3 hours = 0.125 days + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + // Lowest: $30 * 0.125 = $3.75 + $this->assertEquals('3.75', $cartItem->price); + + // Clear cart + $cartItem->delete(); + + // Test HIGHEST + $this->poolProduct->setPricingStrategy(PricingStrategy::HIGHEST); + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + // Highest: $50 * 0.125 = $6.25 + $this->assertEquals('6.25', $cartItem->price); + + // Clear cart + $cartItem->delete(); + + // Test AVERAGE + $this->poolProduct->setPricingStrategy(PricingStrategy::AVERAGE); + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + // Average: ($50 + $30) / 2 = $40, $40 * 0.125 = $5.00 + $this->assertEquals('5.00', $cartItem->price); + } + + /** @test */ + public function it_handles_single_minute_booking() + { + // Just 1 minute + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(10, 1, 0); + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // 1 minute = 0.000694 days (minimum), $30 * 0.000694 = $0.02082, rounds to $0.02 + $this->assertEquals('0.02', $cartItem->price); + } + + /** @test */ + public function it_handles_booking_with_seconds_precision() + { + // 2 hours, 30 minutes, 30 seconds (Carbon truncates seconds to minutes) + $from = Carbon::now()->addDays(5)->setTime(10, 0, 0); + $until = Carbon::now()->addDays(5)->setTime(12, 30, 30); + + $cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until); + + // 150 minutes (seconds truncated), $30 * (150/1440) = $3.125, rounds to $3.13 or $3.14 + $price = (float)$cartItem->price; + $this->assertGreaterThanOrEqual(3.12, $price); + $this->assertLessThanOrEqual(3.14, $price); + } +}