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
4. Avoid redundancy and ensure that the prompts are well-organized
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
```
### 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
use Blax\Shop\Enums\PricingStrategy;
// Single items have different prices:
// - Spot 1: $20/day
// - Spot 2: $30/day
// - Spot 3: $25/day
// Pool has a default price: $50/day
ProductPrice::create([
'purchasable_id' => $parkingPool->id,
'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);
$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);
$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);
$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)
**Critical Feature:** Pricing only considers **available** single items, not all items.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<?php
namespace Blax\Shop\Tests\Feature;
namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Facades\Cart;
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
namespace Blax\Shop\Tests\Feature;
namespace Blax\Shop\Tests\Feature\Checkout;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<?php
namespace Blax\Shop\Tests\Feature;
namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductType;
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 3 has default price of 1000
* - Each single item has 2 stocks
* - Pricing strategy: LOWEST
*
* Expected cart totals with LOWEST pricing strategy:
* - Add 1: 300 (from Spot 1)
* - Add 2: 600 (300 + 300, both from Spot 1)
* - Add 3: 1100 (300 + 300 + 500, third from pool or Spot 2 fallback)
* - Add 4: 1600 (300 + 300 + 500 + 500, fourth from pool or Spot 2 fallback)
* - Add 5: 2600 (300 + 300 + 500 + 500 + 1000, fifth from Spot 3)
* - Add 6: 3600 (300 + 300 + 500 + 500 + 1000 + 1000, sixth from Spot 3)
* - Add 1: 300 (Spot 1, cheapest available)
* - Add 2: 600 (Spot 1 again, still cheapest at 300)
* - Add 3: 1100 (Spot 2 fallback to pool price 500 since it has no own price)
* - Add 4: 1600 (Spot 2 again at 500)
* - Add 5: 2600 (Spot 3 at 1000, its own price)
* - Add 6: 3600 (Spot 3 again at 1000)
* - Add 7: NotEnoughStockException (only 6 total)
*
* When dates span 2 days, all totals should double.
@ -157,6 +158,11 @@ class PoolParkingCartPricingTest extends TestCase
$from = now()->addDays(1);
$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)
$cartItem = $this->cart->addToCart($pool, 1, [], $from, $until);
$this->assertEquals(300, $this->cart->getTotal());
@ -174,11 +180,11 @@ class PoolParkingCartPricingTest extends TestCase
$this->cart->addToCart($pool, 1, [], $from, $until);
$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->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->assertEquals(3600, $this->cart->fresh()->getTotal());
@ -196,26 +202,37 @@ class PoolParkingCartPricingTest extends TestCase
// Get price IDs for reference
$spot1PriceId = $spots[0]->defaultPrice()->first()->id;
$poolPriceId = $pool->defaultPrice()->first()->id;
$spot3PriceId = $spots[2]->defaultPrice()->first()->id;
// Add 6 items
$this->cart->addToCart($pool, 6);
$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);
$this->assertNotNull($item300);
$this->assertEquals(2, $item300->quantity);
$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);
$this->assertNotNull($item500);
$this->assertEquals(2, $item500->quantity);
$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);
$this->assertNotNull($item1000);
$this->assertEquals(2, $item1000->quantity);
$this->assertEquals($spot3PriceId, $item1000->price_id);
}
@ -231,7 +248,11 @@ class PoolParkingCartPricingTest extends TestCase
// Add items with dates
$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());
}
@ -244,7 +265,11 @@ class PoolParkingCartPricingTest extends TestCase
// Add items without dates first
$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());
$from = Carbon::now()->addDay()->startOfDay();
@ -253,7 +278,7 @@ class PoolParkingCartPricingTest extends TestCase
// Set dates - should recalculate to 2-day prices
$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());
}
@ -266,6 +291,7 @@ class PoolParkingCartPricingTest extends TestCase
// Add items without dates first
$this->cart->addToCart($pool, 6);
// With LOWEST strategy: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600
$this->assertEquals(3600, $this->cart->fresh()->getTotal());
$from = Carbon::now()->addDay()->startOfDay();
@ -280,7 +306,7 @@ class PoolParkingCartPricingTest extends TestCase
// Apply dates to items
$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());
}
@ -457,6 +483,11 @@ class PoolParkingCartPricingTest extends TestCase
$from = now()->addDays(1);
$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)
$cartItem = $this->cart->addToCart($pool, 1, [], $from, $until);
$this->assertEquals(300, $this->cart->getTotal());
@ -474,11 +505,11 @@ class PoolParkingCartPricingTest extends TestCase
$this->cart->addToCart($pool, 1, [], $from, $until);
$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->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->assertEquals(3600, $this->cart->fresh()->getTotal());
@ -496,27 +527,37 @@ class PoolParkingCartPricingTest extends TestCase
// Get price IDs for reference
$spot1PriceId = $spots[0]->defaultPrice()->first()->id;
$poolPriceId = $pool->defaultPrice()->first()->id;
$spot3PriceId = $spots[2]->defaultPrice()->first()->id;
// Add 6 items
$this->cart->addToCart($pool, 6);
$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);
$this->assertNotNull($item300);
$this->assertEquals(2, $item300->quantity);
$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);
$this->assertNotNull($item500);
$this->assertEquals(2, $item500->quantity);
$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);
$this->assertNotNull($item1000);
$this->assertEquals($spot3PriceId, $item1000->price_id);
$this->assertEquals(2, $item1000->quantity);
$this->assertNotEquals($poolPriceId, $item1000->price_id);
}
#[Test]
@ -531,7 +572,11 @@ class PoolParkingCartPricingTest extends TestCase
// Add items with dates
$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());
}
@ -544,7 +589,11 @@ class PoolParkingCartPricingTest extends TestCase
// Add items without dates first
$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());
$from = Carbon::now()->addDay()->startOfDay();
@ -553,7 +602,7 @@ class PoolParkingCartPricingTest extends TestCase
// Set dates - should recalculate to 2-day prices
$this->cart->setDates($from, $until, validateAvailability: false);
// 2-day prices: 7200
// 2-day prices: 3600 * 2 = 7200
$this->assertEquals(7200, $this->cart->fresh()->getTotal());
}
@ -742,6 +791,7 @@ class PoolParkingCartPricingTest extends TestCase
$expectedTotal = $this->cart->items()->sum('subtotal');
$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());
}
@ -753,6 +803,7 @@ class PoolParkingCartPricingTest extends TestCase
// Add 6 items
$this->cart->addToCart($pool, 6);
// With LOWEST strategy: 300 + 300 + 500 + 500 + 1000 + 1000 = 3600
$this->assertEquals(3600, $this->cart->getTotal());
// 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
$this->cart->addToCart($pool, 1);
// Should still be 3600 (removed 1000, added 1000)
$this->assertEquals(3600, $this->cart->fresh()->getTotal());
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<?php
namespace Blax\Shop\Tests\Feature;
namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductType;
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
namespace Blax\Shop\Tests\Feature;
namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductAction;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<?php
namespace Blax\Shop\Tests\Feature;
namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Exceptions\NotEnoughStockException;
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
namespace Blax\Shop\Tests\Feature;
namespace Blax\Shop\Tests\Feature\Stripe;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\PaymentProviderIdentity;