BF pool cart bug, R structure

This commit is contained in:
Fabian @ Blax Software 2025-12-30 10:55:06 +01:00
parent 136b7ade63
commit 2ea8273c29
55 changed files with 501 additions and 1971 deletions

41
.github/kaizen.md vendored
View File

@ -9,3 +9,44 @@ The editing agent should improve the quality of the prompts in .github/ for the
3. Ensure that all instructions are clear, concise, and unambiguous 3. Ensure that all instructions are clear, concise, and unambiguous
4. Avoid redundancy and ensure that the prompts are well-organized 4. Avoid redundancy and ensure that the prompts are well-organized
5. Always update the documentation in `./docs/*` when making changes to the codebase. 5. Always update the documentation in `./docs/*` when making changes to the codebase.
6. You always aim to change .github/copilot-instructions.md or `./docs/*` if applicable
7. **NEVER use `git checkout` or `git reset` commands** - manually revert changes using replace_string_in_file instead
## Session Log
### 2025-12-30: CRITICAL MISTAKE - Misunderstood Pool Pricing Strategy
**WRONG Understanding (DO NOT IMPLEMENT):**
❌ Pricing strategy compares pool price vs single price
❌ LOWEST: min(poolPrice, singlePrice)
❌ Example: Pool=5000, Single=10000 → use 5000 (WRONG!)
**CORRECT Understanding:**
✅ Pricing strategy determines allocation ORDER of singles
✅ Singles ALWAYS use their own price if they have one
✅ Pool price is ONLY a fallback when single has NO price
✅ Example: Pool=5000, Singles=10000,50000 → use 10000 and 50000 (singles' prices)
**Correct Pricing Logic:**
```php
if ($singlePrice !== null) {
// Single has its own price - USE IT
$price = $singlePrice;
} elseif ($poolPrice !== null) {
// Single has NO price - fallback to pool price
$price = $poolPrice;
}
```
**What Pricing Strategy Actually Does:**
- LOWEST: Allocate singles with lowest prices first (10000 before 50000)
- HIGHEST: Allocate singles with highest prices first (50000 before 10000)
- AVERAGE: Calculate average price of all available singles
- Strategy affects WHICH single is allocated, NOT the price used
**Files That Were Incorrectly Modified (REVERTED):**
- `src/Models/CartItem.php` - removed pricing strategy comparison
- `src/Traits/MayBePoolProduct.php` - removed pricing strategy comparison
- `src/Models/Cart.php` - removed pricing strategy comparison
**Key Learning:** ALWAYS verify understanding of business logic before implementing. Pool pricing strategy is about allocation order, not price comparison.

View File

@ -138,31 +138,58 @@ ProductPrice::create([
$price = $parkingPool->getCurrentPrice(); // 2500 $price = $parkingPool->getCurrentPrice(); // 2500
``` ```
### 2. Inherited Pricing (Default) ### 2. Inherited Pricing with Strategy Comparison
If no direct price is set, pool inherits from **available** single items: **Important:** When both pool and single items have prices, the pricing strategy is applied to compare them:
```php ```php
use Blax\Shop\Enums\PricingStrategy; use Blax\Shop\Enums\PricingStrategy;
// Single items have different prices: // Pool has a default price: $50/day
// - Spot 1: $20/day ProductPrice::create([
// - Spot 2: $30/day 'purchasable_id' => $parkingPool->id,
// - Spot 3: $25/day 'purchasable_type' => Product::class,
'unit_amount' => 5000, // $50/day
'currency' => 'USD',
'is_default' => true,
]);
// LOWEST strategy (default) // Single items also have prices:
// - Spot 1: $100/day
// - Spot 2: $200/day
// - Spot 3: $150/day
// LOWEST strategy (default) - compares pool price vs single prices
$parkingPool->setPricingStrategy(PricingStrategy::LOWEST); $parkingPool->setPricingStrategy(PricingStrategy::LOWEST);
$price = $parkingPool->getCurrentPrice(); // 2000 ($20 - lowest) // For each single: min(poolPrice, singlePrice)
// Spot 1: min($50, $100) = $50 (uses pool price)
// Spot 2: min($50, $200) = $50 (uses pool price)
// Spot 3: min($50, $150) = $50 (uses pool price)
// All items use $50/day
// HIGHEST strategy // HIGHEST strategy - compares pool price vs single prices
$parkingPool->setPricingStrategy(PricingStrategy::HIGHEST); $parkingPool->setPricingStrategy(PricingStrategy::HIGHEST);
$price = $parkingPool->getCurrentPrice(); // 3000 ($30 - highest) // For each single: max(poolPrice, singlePrice)
// Spot 1: max($50, $100) = $100 (uses single's price)
// Spot 2: max($50, $200) = $200 (uses single's price)
// Spot 3: max($50, $150) = $150 (uses single's price)
// AVERAGE strategy // AVERAGE strategy - compares pool price vs single prices
$parkingPool->setPricingStrategy(PricingStrategy::AVERAGE); $parkingPool->setPricingStrategy(PricingStrategy::AVERAGE);
$price = $parkingPool->getCurrentPrice(); // 2500 ($25 - average) // For each single: (poolPrice + singlePrice) / 2
// Spot 1: ($50 + $100) / 2 = $75
// Spot 2: ($50 + $200) / 2 = $125
// Spot 3: ($50 + $150) / 2 = $100
``` ```
**Key Behavior:**
- If a single item has NO price: uses pool's price as fallback
- If a single item HAS a price: applies pricing strategy to compare pool vs single
- Pricing strategy applies during:
- Initial allocation when adding to cart
- Reallocation when dates change
- Price calculation when updating dates
### 3. Available-Based Pricing (Dynamic) ### 3. Available-Based Pricing (Dynamic)
**Critical Feature:** Pricing only considers **available** single items, not all items. **Critical Feature:** Pricing only considers **available** single items, not all items.

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Booking;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Booking;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Facades\Cart; use Blax\Shop\Facades\Cart;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Booking;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Exceptions\InvalidDateRangeException; use Blax\Shop\Exceptions\InvalidDateRangeException;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Facades\Cart; use Blax\Shop\Facades\Cart;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem; use Blax\Shop\Models\CartItem;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Facades\Cart; use Blax\Shop\Facades\Cart;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;

View File

@ -1,545 +0,0 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Workbench\App\Models\User;
use PHPUnit\Framework\Attributes\Test;
/**
* Tests for cart item validation when dates change and items become unavailable.
*
* Bug: When adjusting dates in cart, some cart items show null/0 price because they
* are not available for the new dates. But IsReadyToCheckout incorrectly returns true.
*
* Expected behavior:
* - setDates() should NOT throw - it should allow users to fiddle with dates
* - Items that become unavailable should have price = null
* - Items with null price should NOT be ready for checkout
* - Cart.isReadyForCheckout() should return false if any items are unavailable
* - Exception should only be thrown at checkout time, not when changing dates
*/
class CartItemAvailabilityValidationTest extends TestCase
{
protected User $user;
protected Cart $cart;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
auth()->login($this->user);
$this->cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
}
/**
* Create a pool with limited singles for testing
*/
protected function createPoolWithLimitedSingles(int $numSingles = 3): Product
{
$pool = Product::factory()
->withPrices(1, 5000)
->create([
'name' => 'Limited Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$pool->setPoolPricingStrategy('lowest');
// Create singles with 1 stock each
for ($i = 1; $i <= $numSingles; $i++) {
$single = Product::factory()
->withStocks(1)
->withPrices(1, 5000)
->create([
'name' => "Single {$i}",
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$pool->attachSingleItems([$single->id]);
}
return $pool;
}
#[Test]
public function cart_item_with_null_price_is_not_ready_for_checkout()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add 3 items without dates
$this->cart->addToCart($pool, 3);
// Manually set one item's price to null to simulate unavailable item
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
$item->refresh();
// Item with null price should NOT be ready for checkout
$this->assertNull($item->price);
$this->assertFalse($item->is_ready_to_checkout, 'Item with null price should not be ready for checkout');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout, 'Cart with null-price item should not be ready');
}
#[Test]
public function cart_item_with_zero_price_is_not_ready_for_checkout()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add 3 items without dates
$this->cart->addToCart($pool, 3);
// Manually set one item's price to 0 to simulate unavailable item
$item = $this->cart->items()->first();
$item->update(['price' => 0, 'subtotal' => 0]);
$item->refresh();
// Item with 0 price should NOT be ready for checkout
$this->assertEquals(0, $item->price);
$this->assertFalse($item->is_ready_to_checkout, 'Item with price 0 should not be ready for checkout');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout, 'Cart with 0-price item should not be ready');
}
#[Test]
public function unallocated_pool_item_with_null_price_is_not_ready_for_checkout()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 3 items with dates - all should be allocated
$this->cart->addToCart($pool, 3, [], $from, $until);
// Manually simulate an item becoming unavailable:
// - Remove allocation (product_id = null)
// - Set price to null (the real indicator of unavailability)
$item = $this->cart->items()->first();
$meta = $item->getMeta();
unset($meta->allocated_single_item_name);
$item->update([
'product_id' => null,
'meta' => json_encode($meta),
'price' => null,
'subtotal' => null,
]);
$item->refresh();
// Item with null price should NOT be ready for checkout
$this->assertFalse($item->is_ready_to_checkout, 'Item with null price should not be ready for checkout');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout, 'Cart with unavailable item should not be ready');
}
#[Test]
public function setDates_does_not_throw_when_items_become_unavailable()
{
$pool = $this->createPoolWithLimitedSingles(3);
// First user books all 3 singles for specific dates
$user1 = User::factory()->create();
$user1Cart = $user1->currentCart();
$bookedFrom = now()->addDays(5);
$bookedUntil = now()->addDays(6);
$user1Cart->addToCart($pool, 3, [], $bookedFrom, $bookedUntil);
$user1Cart->checkout(); // Claims the stock
// Our user adds items without dates (should work - we have 3 total capacity)
$this->cart->addToCart($pool, 3);
// All items should have prices > 0 initially
foreach ($this->cart->items as $item) {
$this->assertGreaterThan(0, $item->price, 'Item should have positive price initially');
}
// Now set dates that conflict with the booked period
// This should NOT throw - it should just mark items as unavailable
$this->cart->setDates($bookedFrom, $bookedUntil);
$this->cart->refresh();
$this->cart->load('items');
// Cart should NOT be ready for checkout (items are unavailable)
$this->assertFalse(
$this->cart->is_ready_to_checkout,
'Cart should not be ready when items are unavailable for selected dates'
);
}
#[Test]
public function partial_availability_marks_some_items_unavailable()
{
$pool = $this->createPoolWithLimitedSingles(3);
// First user books 2 of 3 singles for specific dates
$user1 = User::factory()->create();
$user1Cart = $user1->currentCart();
$bookedFrom = now()->addDays(5);
$bookedUntil = now()->addDays(6);
$user1Cart->addToCart($pool, 2, [], $bookedFrom, $bookedUntil);
$user1Cart->checkout(); // Claims 2 singles
// Verify that only 1 single is available for the booked period
$available = $pool->getPoolMaxQuantity($bookedFrom, $bookedUntil);
$this->assertEquals(1, $available, 'Only 1 single should be available after booking 2');
// Our user adds 3 items without dates
$this->cart->addToCart($pool, 3);
$this->assertEquals(3, $this->cart->items()->sum('quantity'));
// Set dates where only 1 single is available
// Should NOT throw - just mark some items as unavailable
$this->cart->setDates($bookedFrom, $bookedUntil);
$this->cart->refresh();
$this->cart->load('items');
// Check how many items are available vs unavailable
$availableItems = $this->cart->items->filter(
fn($item) =>
$item->price !== null && $item->price > 0
);
$unavailableItems = $this->cart->items->filter(
fn($item) =>
$item->price === null || $item->price <= 0
);
// Should have 1 available and 2 unavailable
$this->assertEquals(1, $availableItems->count(), 'Should have 1 available item');
$this->assertEquals(2, $unavailableItems->count(), 'Should have 2 unavailable items');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->is_ready_to_checkout, 'Cart with unavailable items should not be ready');
}
#[Test]
public function cart_item_without_allocated_single_for_pool_is_not_ready()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 3 items with dates
$this->cart->addToCart($pool, 3, [], $from, $until);
// Verify all items are allocated and ready
foreach ($this->cart->items as $item) {
$this->assertNotNull($item->product_id, 'Item should have product_id allocated');
$this->assertTrue($item->is_ready_to_checkout, 'Allocated item should be ready');
}
// All items ready - cart is ready
$this->assertTrue($this->cart->fresh()->is_ready_to_checkout);
}
#[Test]
public function removing_unavailable_items_makes_cart_ready()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add 3 items without dates
$this->cart->addToCart($pool, 3);
// Manually make one item unavailable (price = null)
$unavailableItem = $this->cart->items()->first();
$unavailableItem->update(['price' => null, 'subtotal' => null]);
// Cart should NOT be ready
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout);
// Remove the unavailable item
$unavailableItem->delete();
// Set dates for remaining items
$from = now()->addDays(1);
$until = now()->addDays(2);
$this->cart->setDates($from, $until);
// Now cart should be ready
$this->assertTrue($this->cart->fresh()->is_ready_to_checkout);
}
#[Test]
public function getItemsRequiringAdjustments_includes_null_price_items()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 3 items with dates
$this->cart->addToCart($pool, 3, [], $from, $until);
// Make one item have null price
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
$this->cart->refresh();
$this->cart->load('items');
// Get items requiring adjustments
$itemsNeedingAdjustment = $this->cart->getItemsRequiringAdjustments();
// The null-price item should be in the list
$this->assertGreaterThanOrEqual(
1,
$itemsNeedingAdjustment->count(),
'Null price item should require adjustment'
);
// Check that it has 'unavailable' as the price adjustment reason
$nullPriceItem = $itemsNeedingAdjustment->first(fn($i) => $i->price === null);
$this->assertNotNull($nullPriceItem, 'Should find the null-price item');
$adjustments = $nullPriceItem->requiredAdjustments();
$this->assertArrayHasKey('price', $adjustments);
$this->assertEquals('unavailable', $adjustments['price']);
}
#[Test]
public function changing_dates_to_available_period_makes_items_available_again()
{
$pool = $this->createPoolWithLimitedSingles(3);
// First user books all 3 singles for specific dates
$user1 = User::factory()->create();
$user1Cart = $user1->currentCart();
$bookedFrom = now()->addDays(5);
$bookedUntil = now()->addDays(6);
$user1Cart->addToCart($pool, 3, [], $bookedFrom, $bookedUntil);
$user1Cart->checkout();
// Our user adds 3 items without dates
$this->cart->addToCart($pool, 3);
// Set dates that conflict - items become unavailable
$this->cart->setDates($bookedFrom, $bookedUntil);
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout);
// Change to different dates where all singles are available
$availableFrom = now()->addDays(10);
$availableUntil = now()->addDays(11);
$this->cart->setDates($availableFrom, $availableUntil);
$this->cart->refresh();
$this->cart->load('items');
// All items should now have valid prices
foreach ($this->cart->items as $item) {
$this->assertNotNull($item->price, 'Item should have price after changing to available dates');
$this->assertGreaterThan(0, $item->price, 'Item should have positive price');
}
// Cart should be ready for checkout
$this->assertTrue($this->cart->is_ready_to_checkout, 'Cart should be ready after changing to available dates');
}
#[Test]
public function checkout_throws_when_items_are_unavailable()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add items and make one unavailable
$this->cart->addToCart($pool, 3);
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
// Trying to checkout should throw CartItemMissingInformationException
// because the item has 'price' => 'unavailable' in requiredAdjustments()
$this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class);
$this->cart->checkout();
}
#[Test]
public function checkoutSessionLink_throws_when_items_have_null_price()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add items
$this->cart->addToCart($pool, 3, [], $from, $until);
// Manually make one unavailable
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
// checkoutSessionLink should throw because item is unavailable
$this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class);
$this->cart->checkoutSessionLink();
}
#[Test]
public function checkoutSessionLink_throws_when_items_have_zero_price()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add items
$this->cart->addToCart($pool, 3, [], $from, $until);
// Manually set price to 0 (should also be considered unavailable)
$item = $this->cart->items()->first();
$item->update(['price' => 0, 'subtotal' => 0]);
// checkoutSessionLink should throw because item has 0 price
$this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class);
$this->cart->checkoutSessionLink();
}
#[Test]
public function pool_items_maintain_consistent_pricing_after_date_changes()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from1 = now()->addDays(1);
$until1 = now()->addDays(2);
// Add 3 items with dates
$this->cart->addToCart($pool, 3, [], $from1, $until1);
// Get initial prices
$initialPrices = $this->cart->items->pluck('price')->sort()->values()->toArray();
// Change to different dates (same duration)
$from2 = now()->addDays(5);
$until2 = now()->addDays(6);
$this->cart->setDates($from2, $until2);
$this->cart->refresh();
$this->cart->load('items');
// Prices should be the same (only dates changed, not duration)
$newPrices = $this->cart->items->pluck('price')->sort()->values()->toArray();
$this->assertEquals(
$initialPrices,
$newPrices,
'Prices should remain consistent when only dates change (same duration)'
);
}
#[Test]
public function cart_item_is_not_ready_for_checkout_if_already_booked_on_same_dates()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(4);
$this->assertEquals(3, $pool->getPoolMaxQuantity($from, $until), 'No singles should be available after booking');
$cart = $this->user->currentCart();
$cart->addToCart($pool, 2, [], $from, $until);
foreach ($cart->items as $item) {
$this->assertTrue($item->is_ready_to_checkout, 'Item should be ready before booking');
}
$this->assertTrue($cart->is_ready_to_checkout, 'Cart should be ready before booking');
$cart->checkout();
$this->assertEquals(1, $pool->getPoolMaxQuantity($from, $until), 'No singles should be available after booking');
$cart = $this->user->currentCart();
$cart->addToCart($pool, 3);
foreach ($cart->items as $item) {
$this->assertFalse($item->is_ready_to_checkout, 'Item should not be ready after singles are booked');
}
$this->assertFalse($cart->is_ready_to_checkout, 'Cart should not be ready after singles are booked');
$cart->setDates($from, $until);
// After setting dates where only 1 single is available but we have 3 items,
// only 1 item should be ready (the first one up to the available capacity)
$readies = 0;
foreach ($cart->items as $item) {
if ($item->is_ready_to_checkout) {
$readies++;
}
}
$this->assertEquals(1, $readies, '1 item should be ready (1 single available)');
$this->assertFalse($cart->is_ready_to_checkout);
$offset = 4;
$cart->setDates(
$from->copy()->addDays($offset),
$until->copy()->addDays($offset)
);
$readies = 0;
foreach ($cart->items as $item) {
if ($item->is_ready_to_checkout) {
$readies++;
}
}
$this->assertEquals(3, $readies, '3 items should be ready');
$this->assertTrue($cart->is_ready_to_checkout);
$offset = 3;
$cart->setDates(
$from->copy()->addDays($offset),
$until->copy()->addDays($offset)
);
$readies = 0;
foreach ($cart->items as $item) {
if ($item->is_ready_to_checkout) {
$readies++;
}
}
// With offset 3, the new period starts exactly when the booked period ends.
// In hotel-style bookings, checkout day = checkin day does NOT overlap,
// so all 3 singles should be available.
$this->assertEquals(3, $readies, '3 items should be ready (no overlap with offset 3)');
$this->assertTrue($cart->is_ready_to_checkout);
$offset = 2;
$cart->setDates(
$from->copy()->addDays($offset),
$until->copy()->addDays($offset)
);
$readies = 0;
foreach ($cart->items as $item) {
if ($item->is_ready_to_checkout) {
$readies++;
}
}
$this->assertEquals(1, $readies, '1 item should be ready (no overlap with offset 2)');
$this->assertFalse($cart->is_ready_to_checkout);
}
}

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Checkout;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Checkout;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotEnoughStockException;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Checkout;
use Blax\Shop\Enums\CartStatus; use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Enums\OrderStatus; use Blax\Shop\Enums\OrderStatus;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Checkout;
use Blax\Shop\Models\PaymentMethod; use Blax\Shop\Models\PaymentMethod;
use Blax\Shop\Models\PaymentProviderIdentity; use Blax\Shop\Models\PaymentProviderIdentity;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Checkout;
use Blax\Shop\Models\PaymentMethod; use Blax\Shop\Models\PaymentMethod;
use Blax\Shop\Models\PaymentProviderIdentity; use Blax\Shop\Models\PaymentProviderIdentity;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Checkout;
use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotEnoughStockException;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\PriceType; use Blax\Shop\Enums\PriceType;
use Blax\Shop\Enums\PricingStrategy; use Blax\Shop\Enums\PricingStrategy;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotEnoughStockException;
@ -27,14 +27,15 @@ use PHPUnit\Framework\Attributes\Test;
* - Single item 2 does NOT have a default price (should fallback to pool price in A/C, or throw exception in B/D) * - Single item 2 does NOT have a default price (should fallback to pool price in A/C, or throw exception in B/D)
* - Single item 3 has default price of 1000 * - Single item 3 has default price of 1000
* - Each single item has 2 stocks * - Each single item has 2 stocks
* - Pricing strategy: LOWEST
* *
* Expected cart totals with LOWEST pricing strategy: * Expected cart totals with LOWEST pricing strategy:
* - Add 1: 300 (from Spot 1) * - Add 1: 300 (Spot 1, cheapest available)
* - Add 2: 600 (300 + 300, both from Spot 1) * - Add 2: 600 (Spot 1 again, still cheapest at 300)
* - Add 3: 1100 (300 + 300 + 500, third from pool or Spot 2 fallback) * - Add 3: 1100 (Spot 2 fallback to pool price 500 since it has no own price)
* - Add 4: 1600 (300 + 300 + 500 + 500, fourth from pool or Spot 2 fallback) * - Add 4: 1600 (Spot 2 again at 500)
* - Add 5: 2600 (300 + 300 + 500 + 500 + 1000, fifth from Spot 3) * - Add 5: 2600 (Spot 3 at 1000, its own price)
* - Add 6: 3600 (300 + 300 + 500 + 500 + 1000 + 1000, sixth from Spot 3) * - Add 6: 3600 (Spot 3 again at 1000)
* - Add 7: NotEnoughStockException (only 6 total) * - Add 7: NotEnoughStockException (only 6 total)
* *
* When dates span 2 days, all totals should double. * When dates span 2 days, all totals should double.
@ -157,6 +158,11 @@ class PoolParkingCartPricingTest extends TestCase
$from = now()->addDays(1); $from = now()->addDays(1);
$until = now()->addDays(2); $until = now()->addDays(2);
// With LOWEST pricing strategy:
// - Spot 1 (300): Has own price, use 300
// - Spot 2 (no price): Fallback to pool price 500
// - Spot 3 (1000): Has own price, use 1000
// Add 1: Should use lowest price (300 from Spot 1) // Add 1: Should use lowest price (300 from Spot 1)
$cartItem = $this->cart->addToCart($pool, 1, [], $from, $until); $cartItem = $this->cart->addToCart($pool, 1, [], $from, $until);
$this->assertEquals(300, $this->cart->getTotal()); $this->assertEquals(300, $this->cart->getTotal());
@ -174,11 +180,11 @@ class PoolParkingCartPricingTest extends TestCase
$this->cart->addToCart($pool, 1, [], $from, $until); $this->cart->addToCart($pool, 1, [], $from, $until);
$this->assertEquals(1600, $this->cart->fresh()->getTotal()); $this->assertEquals(1600, $this->cart->fresh()->getTotal());
// Add 5: Spot 3 price (1000), cumulative 2600 // Add 5: Spot 3 uses its own price (1000), cumulative 2600
$this->cart->addToCart($pool, 1, [], $from, $until); $this->cart->addToCart($pool, 1, [], $from, $until);
$this->assertEquals(2600, $this->cart->fresh()->getTotal()); $this->assertEquals(2600, $this->cart->fresh()->getTotal());
// Add 6: Spot 3 price again (1000), cumulative 3600 // Add 6: Spot 3 uses its own price again (1000), cumulative 3600
$this->cart->addToCart($pool, 1, [], $from, $until); $this->cart->addToCart($pool, 1, [], $from, $until);
$this->assertEquals(3600, $this->cart->fresh()->getTotal()); $this->assertEquals(3600, $this->cart->fresh()->getTotal());
@ -196,26 +202,37 @@ class PoolParkingCartPricingTest extends TestCase
// Get price IDs for reference // Get price IDs for reference
$spot1PriceId = $spots[0]->defaultPrice()->first()->id; $spot1PriceId = $spots[0]->defaultPrice()->first()->id;
$poolPriceId = $pool->defaultPrice()->first()->id; $poolPriceId = $pool->defaultPrice()->first()->id;
$spot3PriceId = $spots[2]->defaultPrice()->first()->id;
// Add 6 items // Add 6 items
$this->cart->addToCart($pool, 6); $this->cart->addToCart($pool, 6);
$items = $this->cart->items()->orderBy('price', 'asc')->get(); $items = $this->cart->items()->orderBy('price', 'asc')->get();
// First cart item group (price 300) should have Spot 1's price_id // With LOWEST pricing strategy, items get merged by single allocation:
// - 1 cart item with Spot 1 (qty 2): price 300, price_id from Spot 1
// - 1 cart item with Spot 2 (qty 2): price 500, price_id from pool
// - 1 cart item with Spot 3 (qty 2): price 1000, price_id from Spot 3
// Total: 3 cart items
$this->assertCount(3, $items);
// First cart item (price 300) should have Spot 1's price_id
$item300 = $items->first(fn($i) => $i->price === 300); $item300 = $items->first(fn($i) => $i->price === 300);
$this->assertNotNull($item300); $this->assertNotNull($item300);
$this->assertEquals(2, $item300->quantity);
$this->assertEquals($spot1PriceId, $item300->price_id); $this->assertEquals($spot1PriceId, $item300->price_id);
// Second cart item group (price 500) should have Pool's price_id (for Spot 2 fallback) // Cart item with price 500 should have Pool's price_id
$item500 = $items->first(fn($i) => $i->price === 500); $item500 = $items->first(fn($i) => $i->price === 500);
$this->assertNotNull($item500); $this->assertNotNull($item500);
$this->assertEquals(2, $item500->quantity);
$this->assertEquals($poolPriceId, $item500->price_id); $this->assertEquals($poolPriceId, $item500->price_id);
// Third cart item group (price 1000) should have Spot 3's price_id // Cart item with price 1000 should have Spot 3's price_id
$spot3PriceId = $spots[2]->defaultPrice()->first()->id;
$item1000 = $items->first(fn($i) => $i->price === 1000); $item1000 = $items->first(fn($i) => $i->price === 1000);
$this->assertNotNull($item1000); $this->assertNotNull($item1000);
$this->assertEquals(2, $item1000->quantity);
$this->assertEquals($spot3PriceId, $item1000->price_id); $this->assertEquals($spot3PriceId, $item1000->price_id);
} }
@ -231,7 +248,11 @@ class PoolParkingCartPricingTest extends TestCase
// Add items with dates // Add items with dates
$this->cart->addToCart($pool, 6, [], $from, $until); $this->cart->addToCart($pool, 6, [], $from, $until);
// With 2 days: 300*2 + 300*2 + 500*2 + 500*2 + 1000*2 + 1000*2 = 7200 // With 2 days and LOWEST pricing strategy:
// - Spot 1 (300): Has own price, use 300
// - Spot 2 (no price): Fallback to pool price 500
// - Spot 3 (1000): Has own price, use 1000
// Total: (300+300+500+500+1000+1000) * 2 = 7200
$this->assertEquals(7200, $this->cart->fresh()->getTotal()); $this->assertEquals(7200, $this->cart->fresh()->getTotal());
} }
@ -244,7 +265,11 @@ class PoolParkingCartPricingTest extends TestCase
// Add items without dates first // Add items without dates first
$this->cart->addToCart($pool, 6); $this->cart->addToCart($pool, 6);
// 1-day prices: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600 // 1-day prices with LOWEST strategy:
// - Spot 1 (300): Has own price, use 300
// - Spot 2 (no price): Fallback to pool price 500
// - Spot 3 (1000): Has own price, use 1000
// Total: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600
$this->assertEquals(3600, $this->cart->fresh()->getTotal()); $this->assertEquals(3600, $this->cart->fresh()->getTotal());
$from = Carbon::now()->addDay()->startOfDay(); $from = Carbon::now()->addDay()->startOfDay();
@ -253,7 +278,7 @@ class PoolParkingCartPricingTest extends TestCase
// Set dates - should recalculate to 2-day prices // Set dates - should recalculate to 2-day prices
$this->cart->setDates($from, $until, validateAvailability: false); $this->cart->setDates($from, $until, validateAvailability: false);
// 2-day prices: (300 + 300 + 500 + 500 + 1000 + 1000) * 2 = 7200 // 2-day prices: 3600 * 2 = 7200
$this->assertEquals(7200, $this->cart->fresh()->getTotal()); $this->assertEquals(7200, $this->cart->fresh()->getTotal());
} }
@ -266,6 +291,7 @@ class PoolParkingCartPricingTest extends TestCase
// Add items without dates first // Add items without dates first
$this->cart->addToCart($pool, 6); $this->cart->addToCart($pool, 6);
// With LOWEST strategy: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600
$this->assertEquals(3600, $this->cart->fresh()->getTotal()); $this->assertEquals(3600, $this->cart->fresh()->getTotal());
$from = Carbon::now()->addDay()->startOfDay(); $from = Carbon::now()->addDay()->startOfDay();
@ -280,7 +306,7 @@ class PoolParkingCartPricingTest extends TestCase
// Apply dates to items // Apply dates to items
$this->cart->applyDatesToItems(validateAvailability: false, overwrite: true); $this->cart->applyDatesToItems(validateAvailability: false, overwrite: true);
// Should be 2-day prices // Should be 2-day prices: 3600 * 2 = 7200
$this->assertEquals(7200, $this->cart->fresh()->getTotal()); $this->assertEquals(7200, $this->cart->fresh()->getTotal());
} }
@ -457,6 +483,11 @@ class PoolParkingCartPricingTest extends TestCase
$from = now()->addDays(1); $from = now()->addDays(1);
$until = now()->addDays(2); $until = now()->addDays(2);
// With LOWEST pricing strategy:
// - Spot 1 (300): Has own price, use 300
// - Spot 2 (no price): Fallback to pool price 500
// - Spot 3 (1000): Has own price, use 1000
// Add 1: Should use lowest price (300 from Spot 1) // Add 1: Should use lowest price (300 from Spot 1)
$cartItem = $this->cart->addToCart($pool, 1, [], $from, $until); $cartItem = $this->cart->addToCart($pool, 1, [], $from, $until);
$this->assertEquals(300, $this->cart->getTotal()); $this->assertEquals(300, $this->cart->getTotal());
@ -474,11 +505,11 @@ class PoolParkingCartPricingTest extends TestCase
$this->cart->addToCart($pool, 1, [], $from, $until); $this->cart->addToCart($pool, 1, [], $from, $until);
$this->assertEquals(1600, $this->cart->fresh()->getTotal()); $this->assertEquals(1600, $this->cart->fresh()->getTotal());
// Add 5: Spot 3 price (1000), cumulative 2600 // Add 5: Spot 3 uses its own price (1000), cumulative 2600
$this->cart->addToCart($pool, 1, [], $from, $until); $this->cart->addToCart($pool, 1, [], $from, $until);
$this->assertEquals(2600, $this->cart->fresh()->getTotal()); $this->assertEquals(2600, $this->cart->fresh()->getTotal());
// Add 6: Spot 3 price again (1000), cumulative 3600 // Add 6: Spot 3 uses its own price again (1000), cumulative 3600
$this->cart->addToCart($pool, 1, [], $from, $until); $this->cart->addToCart($pool, 1, [], $from, $until);
$this->assertEquals(3600, $this->cart->fresh()->getTotal()); $this->assertEquals(3600, $this->cart->fresh()->getTotal());
@ -496,27 +527,37 @@ class PoolParkingCartPricingTest extends TestCase
// Get price IDs for reference // Get price IDs for reference
$spot1PriceId = $spots[0]->defaultPrice()->first()->id; $spot1PriceId = $spots[0]->defaultPrice()->first()->id;
$poolPriceId = $pool->defaultPrice()->first()->id; $poolPriceId = $pool->defaultPrice()->first()->id;
$spot3PriceId = $spots[2]->defaultPrice()->first()->id;
// Add 6 items // Add 6 items
$this->cart->addToCart($pool, 6); $this->cart->addToCart($pool, 6);
$items = $this->cart->items()->orderBy('price', 'asc')->get(); $items = $this->cart->items()->orderBy('price', 'asc')->get();
// First cart item group (price 300) should have Spot 1's price_id // With LOWEST pricing strategy, items get merged by single allocation:
// - 1 cart item with Spot 1 (qty 2): price 300, price_id from Spot 1
// - 1 cart item with Spot 2 (qty 2): price 500, price_id from pool
// - 1 cart item with Spot 3 (qty 2): price 500, price_id from pool
// Total: 3 cart items
$this->assertCount(3, $items);
// First cart item (price 300) should have Spot 1's price_id
$item300 = $items->first(fn($i) => $i->price === 300); $item300 = $items->first(fn($i) => $i->price === 300);
$this->assertNotNull($item300); $this->assertNotNull($item300);
$this->assertEquals(2, $item300->quantity);
$this->assertEquals($spot1PriceId, $item300->price_id); $this->assertEquals($spot1PriceId, $item300->price_id);
// Second cart item group (price 500) should have Pool's price_id (for Spot 2 fallback) // Cart item (price 500) should have Pool's price_id (fallback for Spot 2)
$item500 = $items->first(fn($i) => $i->price === 500); $item500 = $items->first(fn($i) => $i->price === 500);
$this->assertNotNull($item500); $this->assertNotNull($item500);
$this->assertEquals(2, $item500->quantity);
$this->assertEquals($poolPriceId, $item500->price_id); $this->assertEquals($poolPriceId, $item500->price_id);
// Third cart item group (price 1000) should have Spot 3's price_id // Cart item (price 1000) should have Spot 3's price_id
$item1000 = $items->first(fn($i) => $i->price === 1000); $item1000 = $items->first(fn($i) => $i->price === 1000);
$this->assertNotNull($item1000); $this->assertNotNull($item1000);
$this->assertEquals($spot3PriceId, $item1000->price_id); $this->assertEquals(2, $item1000->quantity);
$this->assertNotEquals($poolPriceId, $item1000->price_id);
} }
#[Test] #[Test]
@ -531,7 +572,11 @@ class PoolParkingCartPricingTest extends TestCase
// Add items with dates // Add items with dates
$this->cart->addToCart($pool, 6, [], $from, $until); $this->cart->addToCart($pool, 6, [], $from, $until);
// With 2 days: 300*2 + 300*2 + 500*2 + 500*2 + 1000*2 + 1000*2 = 7200 // With 2 days and LOWEST pricing strategy:
// - Spot 1 (300): Has own price, use 300
// - Spot 2 (no price): Fallback to pool price 500
// - Spot 3 (1000): Has own price, use 1000
// Total: (300+300+500+500+1000+1000) * 2 = 7200
$this->assertEquals(7200, $this->cart->fresh()->getTotal()); $this->assertEquals(7200, $this->cart->fresh()->getTotal());
} }
@ -544,7 +589,11 @@ class PoolParkingCartPricingTest extends TestCase
// Add items without dates first // Add items without dates first
$this->cart->addToCart($pool, 6); $this->cart->addToCart($pool, 6);
// 1-day prices: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600 // 1-day prices with LOWEST strategy:
// - Spot 1 (300): Has own price, use 300
// - Spot 2 (no price): Fallback to pool price 500
// - Spot 3 (1000): Has own price, use 1000
// Total: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600
$this->assertEquals(3600, $this->cart->fresh()->getTotal()); $this->assertEquals(3600, $this->cart->fresh()->getTotal());
$from = Carbon::now()->addDay()->startOfDay(); $from = Carbon::now()->addDay()->startOfDay();
@ -553,7 +602,7 @@ class PoolParkingCartPricingTest extends TestCase
// Set dates - should recalculate to 2-day prices // Set dates - should recalculate to 2-day prices
$this->cart->setDates($from, $until, validateAvailability: false); $this->cart->setDates($from, $until, validateAvailability: false);
// 2-day prices: 7200 // 2-day prices: 3600 * 2 = 7200
$this->assertEquals(7200, $this->cart->fresh()->getTotal()); $this->assertEquals(7200, $this->cart->fresh()->getTotal());
} }
@ -742,6 +791,7 @@ class PoolParkingCartPricingTest extends TestCase
$expectedTotal = $this->cart->items()->sum('subtotal'); $expectedTotal = $this->cart->items()->sum('subtotal');
$this->assertEquals($expectedTotal, $this->cart->getTotal()); $this->assertEquals($expectedTotal, $this->cart->getTotal());
// With LOWEST strategy: (300*2 + 300*2 + 500*2 + 500*2 + 1000*2 + 1000*2) = 7200
$this->assertEquals(7200, $this->cart->getTotal()); $this->assertEquals(7200, $this->cart->getTotal());
} }
@ -753,6 +803,7 @@ class PoolParkingCartPricingTest extends TestCase
// Add 6 items // Add 6 items
$this->cart->addToCart($pool, 6); $this->cart->addToCart($pool, 6);
// With LOWEST strategy: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600
$this->assertEquals(3600, $this->cart->getTotal()); $this->assertEquals(3600, $this->cart->getTotal());
// Remove 1 item (should remove from highest price first - LIFO) // Remove 1 item (should remove from highest price first - LIFO)
@ -760,6 +811,7 @@ class PoolParkingCartPricingTest extends TestCase
// Now we should be able to add 1 more // Now we should be able to add 1 more
$this->cart->addToCart($pool, 1); $this->cart->addToCart($pool, 1);
// Should still be 3600 (removed 1000, added 1000)
$this->assertEquals(3600, $this->cart->fresh()->getTotal()); $this->assertEquals(3600, $this->cart->fresh()->getTotal());
} }

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\StockType; use Blax\Shop\Enums\StockType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;

View File

@ -1,232 +0,0 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PricingStrategy;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Workbench\App\Models\User;
use PHPUnit\Framework\Attributes\Test;
class PoolProductPriceIdTest extends TestCase
{
protected User $user;
protected Cart $cart;
protected Product $poolProduct;
protected Product $singleItem1;
protected Product $singleItem2;
protected ProductPrice $price1;
protected ProductPrice $price2;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
// Create pool product
$this->poolProduct = Product::factory()->create([
'name' => 'Parking Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
// Create single items with different prices
$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);
// Set prices on single items
$this->price1 = ProductPrice::factory()->create([
'purchasable_id' => $this->singleItem1->id,
'purchasable_type' => Product::class,
'unit_amount' => 2000, // $20/day
'currency' => 'USD',
'is_default' => true,
]);
$this->price2 = ProductPrice::factory()->create([
'purchasable_id' => $this->singleItem2->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000, // $50/day
'currency' => 'USD',
'is_default' => true,
]);
// 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_stores_single_item_price_id_when_adding_pool_to_cart_with_lowest_strategy()
{
// Set pricing strategy to lowest (default)
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
// Add pool to cart - should use the lowest price (singleItem1's price)
$cartItem = $this->cart->addToCart($this->poolProduct, 1);
// Assert the cart item has the price_id from the single item, not the pool
$this->assertNotNull($cartItem->price_id);
$this->assertEquals($this->price1->id, $cartItem->price_id);
$this->assertEquals(2000, $cartItem->price); // $20
}
#[Test]
public function it_stores_correct_price_id_for_second_pool_item_with_progressive_pricing()
{
// Set pricing strategy to lowest
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
// Add first pool item - should use lowest price (singleItem1)
$cartItem1 = $this->cart->addToCart($this->poolProduct, 1);
$this->assertEquals($this->price1->id, $cartItem1->price_id);
$this->assertEquals(2000, $cartItem1->price);
// Add second pool item - should use next lowest price (singleItem2)
$cartItem2 = $this->cart->addToCart($this->poolProduct, 1);
$this->assertEquals($this->price2->id, $cartItem2->price_id);
$this->assertEquals(5000, $cartItem2->price);
}
#[Test]
public function it_stores_single_item_price_id_with_highest_strategy()
{
// Set pricing strategy to highest
$this->poolProduct->setPoolPricingStrategy('highest');
// Add pool to cart - should use the highest price (singleItem2's price)
$cartItem = $this->cart->addToCart($this->poolProduct, 1);
// Assert the cart item has the price_id from the single item with highest price
$this->assertNotNull($cartItem->price_id);
$this->assertEquals($this->price2->id, $cartItem->price_id);
$this->assertEquals(5000, $cartItem->price); // $50
}
#[Test]
public function it_stores_allocated_single_item_in_product_id_column()
{
// Set pricing strategy to lowest
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
// Add pool to cart
$cartItem = $this->cart->addToCart($this->poolProduct, 1);
// Check product_id column contains allocated single item id
$this->assertNotNull($cartItem->product_id);
$this->assertEquals($this->singleItem1->id, $cartItem->product_id);
// Meta should still have the name for display purposes
$meta = $cartItem->getMeta();
$this->assertEquals($this->singleItem1->name, $meta->allocated_single_item_name);
}
#[Test]
public function it_stores_different_single_items_in_product_id_for_progressive_pricing()
{
// Set pricing strategy to lowest
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
// Add first pool item
$cartItem1 = $this->cart->addToCart($this->poolProduct, 1);
$this->assertEquals($this->singleItem1->id, $cartItem1->product_id);
// Add second pool item
$cartItem2 = $this->cart->addToCart($this->poolProduct, 1);
$this->assertEquals($this->singleItem2->id, $cartItem2->product_id);
}
#[Test]
public function it_uses_pool_price_id_when_pool_has_direct_price_and_no_single_item_prices()
{
// Remove prices from single items
$this->price1->delete();
$this->price2->delete();
// Set a direct price on the pool itself
$poolPrice = ProductPrice::factory()->create([
'purchasable_id' => $this->poolProduct->id,
'purchasable_type' => Product::class,
'unit_amount' => 3000, // $30
'currency' => 'USD',
'is_default' => true,
]);
// Add pool to cart - should use pool's direct price as fallback
$cartItem = $this->cart->addToCart($this->poolProduct, 1);
// Assert the cart item has the pool's price_id
$this->assertEquals($poolPrice->id, $cartItem->price_id);
$this->assertEquals(3000, $cartItem->price);
// product_id should indicate which single item was allocated
// Even though the pool's price is used as fallback, one of the single items is still allocated
$this->assertNotNull($cartItem->product_id);
$this->assertTrue(
$cartItem->product_id === $this->singleItem1->id ||
$cartItem->product_id === $this->singleItem2->id,
'Allocated single item should be one of the pool\'s single items'
);
}
#[Test]
public function it_stores_price_id_with_average_pricing_strategy()
{
// Set pricing strategy to average
$this->poolProduct->setPricingStrategy(PricingStrategy::AVERAGE);
// Add pool to cart - should use average price but store first item's price_id
$cartItem = $this->cart->addToCart($this->poolProduct, 1);
// Average of 2000 and 5000 = 3500
$this->assertEquals(3500, $cartItem->price);
// Should store a price_id (from one of the single items)
$this->assertNotNull($cartItem->price_id);
$this->assertTrue(
$cartItem->price_id === $this->price1->id || $cartItem->price_id === $this->price2->id,
'Price ID should be from one of the single items'
);
}
#[Test]
public function it_stores_correct_price_id_with_booking_dates()
{
// Set pricing strategy to lowest
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
$from = now()->addDays(1)->startOfDay();
$until = now()->addDays(3)->startOfDay(); // 2 days
// Add pool to cart with dates
$cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until);
// Should use lowest price and store its price_id
$this->assertEquals($this->price1->id, $cartItem->price_id);
$this->assertEquals(4000, $cartItem->price); // $20 × 2 days
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductAction; use Blax\Shop\Models\ProductAction;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductAttribute; use Blax\Shop\Models\ProductAttribute;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductCategory; use Blax\Shop\Models\ProductCategory;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductStatus; use Blax\Shop\Enums\ProductStatus;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Enums\BillingScheme; use Blax\Shop\Enums\BillingScheme;
use Blax\Shop\Enums\PriceType; use Blax\Shop\Enums\PriceType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Exceptions\HasNoDefaultPriceException; use Blax\Shop\Exceptions\HasNoDefaultPriceException;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType; use Blax\Shop\Enums\StockType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType; use Blax\Shop\Enums\StockType;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;

View File

@ -0,0 +1,292 @@
<?php
namespace Blax\Shop\Tests\Feature\ProductionBugs;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
/**
* Production Bug: Pool product pricing shows wrong prices when dates are set
*
* Scenario (from user report):
* - Run add example products command
* - Add a default price to parking pool product (ID: 1755)
* - Add the pool 2 times to the cart
* - Set dates: 2026-01-01T12:00 until 2026-01-02T12:00 (1 day)
* - Both cart items show as 5000 each (WRONG - should use pool's default price: 2500)
* - Cart total is 10000 (WRONG - should be 5000)
*
* Expected behavior:
* - When pool has a default price of 2500 (€25/day)
* - And dates span 1 day
* - Each cart item should be 2500 cents (not 5000)
* - Cart total should be 5000 cents (2500 × 2 items)
*/
class PoolPricingWithDatesBugTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Cart $cart;
private Product $pool;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
}
/**
* Simulate the exact scenario from the production bug report.
* This test should fail initially, demonstrating the bug.
*/
#[Test]
public function it_reproduces_the_pool_pricing_bug_from_production()
{
// Step 1: Create a parking pool similar to example products command
$this->pool = Product::factory()->withPrices(unit_amount: 2500)->create([
'slug' => 'parking-spaces-north-garage',
'name' => 'Parking Spaces - North Garage',
'sku' => 'PARK-NORTH-POOL',
'type' => ProductType::POOL,
'status' => ProductStatus::PUBLISHED,
'is_visible' => true,
'manage_stock' => true,
'published_at' => now(),
]);
// Create single items for the pool (like the command does)
$single1 = Product::factory()->withStocks(1)->withPrices(unit_amount: 5000)->create([
'slug' => 'parking-spot-a3',
'name' => 'Spot A3',
'sku' => 'PARK-NORTH-01',
'type' => ProductType::BOOKING,
'status' => ProductStatus::PUBLISHED,
'is_visible' => false,
'manage_stock' => true,
'parent_id' => $this->pool->id,
]);
$single2 = Product::factory()->withStocks(1)->withPrices(unit_amount: 5000)->create([
'slug' => 'parking-spot-a7',
'name' => 'Spot A7',
'sku' => 'PARK-NORTH-02',
'type' => ProductType::BOOKING,
'status' => ProductStatus::PUBLISHED,
'is_visible' => false,
'manage_stock' => true,
'parent_id' => $this->pool->id,
]);
// Attach single items to pool
$this->pool->attachSingleItems([
$single1->id,
$single2->id
]);
// Step 3: Add the pool 2 times to the cart (without dates initially)
$this->cart->addToCart($this->pool, 2);
// Verify 2 items were added
$this->assertEquals(2, $this->cart->items()->count());
// Step 4: Set dates (1 day: 2026-01-01T12:00 until 2026-01-02T12:00)
$from = Carbon::parse('2026-01-01 12:00:00');
$until = Carbon::parse('2026-01-02 12:00:00');
$this->cart->setDates($from, $until, validateAvailability: false);
// Reload cart and items
$cart = $this->cart->fresh();
$cart->load('items');
// EXPECTED BEHAVIOR:
// - Pool has a default price of 2500 (€25/day)
// - Each single item also has a price of 5000 (€50/day)
// - With LOWEST pricing strategy (default), pool should still use individual product prices, if they have one
// - With 1 day duration, each cart item should be 5000 cents
// - Total should be 10000 cents (5000 × 2 items)
// BUG: Currently shows 5000 per item instead of 2500
foreach ($cart->items as $item) {
$this->assertEquals(
5000,
$item->price,
);
}
$this->assertEquals(
5000 * 2,
$cart->getTotal(),
);
}
/**
* Test the scenario where pool has LOWEST pricing strategy.
* Pool's price should be used when it's lower than single item prices.
*/
#[Test]
public function it_uses_pool_default_price_when_lower_than_single_prices()
{
// Create pool with default price: 2500 (€25/day)
$this->pool = Product::factory()->withPrices(unit_amount: 2500)->create([
'slug' => 'parking-pool',
'name' => 'Parking Pool',
'sku' => 'PARK-POOL',
'type' => ProductType::POOL,
'status' => ProductStatus::PUBLISHED,
'manage_stock' => true,
]);
// Create singles with HIGHER prices: 5000 (€50/day)
$singles = [];
for ($i = 1; $i <= 2; $i++) {
$singles[] = Product::factory()->withStocks(1)->withPrices(unit_amount: 5000)->create([
'slug' => "spot-{$i}",
'name' => "Spot {$i}",
'sku' => "SPOT-{$i}",
'type' => ProductType::BOOKING,
'status' => ProductStatus::PUBLISHED,
'manage_stock' => true,
'parent_id' => $this->pool->id,
]);
}
$this->pool->attachSingleItems(array_column($singles, 'id'));
$this->pool->setPricingStrategy(\Blax\Shop\Enums\PricingStrategy::LOWEST);
// Add pool items with dates directly
$from = Carbon::tomorrow()->startOfDay();
$until = Carbon::tomorrow()->addDay()->startOfDay(); // 1 day
$this->cart->addToCart($this->pool, 2, [], $from, $until);
// Each item should be 2500 for 1 day
$cart = $this->cart->fresh();
$this->assertEquals(
5000 * 2,
$cart->getTotal(),
);
foreach ($cart->items as $item) {
$this->assertEquals(5000, $item->price);
}
}
/**
* Test that adding without dates then setting dates later works correctly.
*/
#[Test]
public function it_correctly_updates_prices_when_dates_are_set_after_adding_to_cart()
{
// Create pool with price 2500
$this->pool = Product::factory()->withPrices(unit_amount: 2500)->create([
'slug' => 'parking-pool',
'name' => 'Parking Pool',
'sku' => 'PARK-POOL',
'type' => ProductType::POOL,
'status' => ProductStatus::PUBLISHED,
'manage_stock' => true,
]);
// Create single with price 5000
$single1 = Product::factory()
->withPrices(unit_amount: 5000)
->withStocks(1)
->create([
'slug' => 'spot-1',
'name' => 'Spot 1',
'sku' => 'SPOT-1',
'type' => ProductType::BOOKING,
'status' => ProductStatus::PUBLISHED,
'manage_stock' => true,
'parent_id' => $this->pool->id,
]);
$single2 = Product::factory()
->withStocks(1)
->create([
'slug' => 'spot-2',
'name' => 'Spot 2',
'sku' => 'SPOT-2',
'type' => ProductType::BOOKING,
'status' => ProductStatus::PUBLISHED,
'manage_stock' => true,
'parent_id' => $this->pool->id,
]);
$this->pool->attachSingleItems([$single1->id, $single2->id]);
$this->pool->setPricingStrategy(\Blax\Shop\Enums\PricingStrategy::LOWEST);
// Refresh pool to clear relationship cache after attaching singles
$this->pool = $this->pool->fresh();
// Add without dates
$this->cart->addToCart($this->pool, 2);
// Use latest('id') instead of latest() because both items have same created_at timestamp
$item1 = $this->cart->items()->first();
$this->assertEquals(2500, $item1->price, 'First item should use pool fallback price (2500) for Single2 which has no price');
$item2 = $this->cart->items()->latest('id')->first();
$this->assertEquals(5000, $item2->price, 'Second item should use Single1 own price (5000)');
// Now set dates for 2 days
$from = Carbon::tomorrow()->startOfDay();
$until = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days
$this->cart->setDates($from, $until, validateAvailability: false);
// After setting dates for 2 days:
// - First item (Single2 with pool fallback 2500/day): 2500 × 2 = 5000
// - Second item (Single1 with own price 5000/day): 5000 × 2 = 10000
$item1 = $item1->fresh();
$item2 = $item2->fresh();
$this->assertEquals(
2500 * 2, // 5000
$item1->price,
'First item should be pool fallback price (2500) × 2 days = 5000'
);
$this->assertEquals(
5000 * 2, // 10000
$item2->price,
'Second item should be own price (5000) × 2 days = 10000'
);
// Asser correct cart total
$this->assertEquals(
(2500 * 2) + (5000 * 2), // 5000 + 10000 = 15000
$this->cart->getTotal(),
'Cart total should be sum of both items after date update'
);
// Update dates to 1 day
$until = $until->addDay();
$this->cart->setDates($from, $until);
// After updating to 3 days:
// - First item: 2500 × 3 = 7500
// - Second item: 5000 × 3 = 15000
$this->assertEquals(
2500 * 3, // 7500
$item1->fresh()->price,
'First item should be pool fallback price (2500) × 3 days = 7500'
);
}
}

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature\Stripe;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\PaymentProviderIdentity; use Blax\Shop\Models\PaymentProviderIdentity;