diff --git a/src/Casts/HtmlDateTimeCast.php b/src/Casts/HtmlDateTimeCast.php
new file mode 100644
index 0000000..44695e3
--- /dev/null
+++ b/src/Casts/HtmlDateTimeCast.php
@@ -0,0 +1,70 @@
+created_at->format('Y-m-d\TH:i')
+ */
+class HtmlDateTimeCast implements CastsAttributes
+{
+ /**
+ * Cast the given value.
+ *
+ * @param array $attributes
+ */
+ public function get(Model $model, string $key, mixed $value, array $attributes): ?Carbon
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ // Convert timestamp to Carbon
+ return Carbon::createFromTimestamp($value);
+ }
+
+ /**
+ * Prepare the given value for storage.
+ *
+ * @param array $attributes
+ */
+ public function set(Model $model, string $key, mixed $value, array $attributes): ?int
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ // Handle Carbon instances
+ if ($value instanceof Carbon) {
+ return $value->timestamp;
+ }
+
+ // Handle DateTimeInterface
+ if ($value instanceof \DateTimeInterface) {
+ return Carbon::instance($value)->timestamp;
+ }
+
+ // Handle string input (including HTML5 datetime-local format)
+ if (is_string($value)) {
+ return Carbon::parse($value)->timestamp;
+ }
+
+ // Handle numeric timestamp
+ if (is_numeric($value)) {
+ return (int) $value;
+ }
+
+ throw new \InvalidArgumentException(
+ "Invalid datetime value for {$key}. Expected string, DateTimeInterface, Carbon, or timestamp."
+ );
+ }
+}
diff --git a/src/Models/Cart.php b/src/Models/Cart.php
index 3d3b9cf..4de90a1 100644
--- a/src/Models/Cart.php
+++ b/src/Models/Cart.php
@@ -2,6 +2,7 @@
namespace Blax\Shop\Models;
+use Blax\Shop\Casts\HtmlDateTimeCast;
use Blax\Shop\Contracts\Cartable;
use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Enums\ProductType;
@@ -40,8 +41,8 @@ class Cart extends Model
'converted_at' => 'datetime',
'last_activity_at' => 'datetime',
'meta' => 'object',
- 'from_date' => 'datetime',
- 'until_date' => 'datetime',
+ 'from_date' => HtmlDateTimeCast::class,
+ 'until_date' => HtmlDateTimeCast::class,
];
protected $appends = [
diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php
index d54c03e..6702a9c 100644
--- a/src/Models/CartItem.php
+++ b/src/Models/CartItem.php
@@ -2,6 +2,7 @@
namespace Blax\Shop\Models;
+use Blax\Shop\Casts\HtmlDateTimeCast;
use Blax\Shop\Exceptions\InvalidDateRangeException;
use Blax\Workkit\Traits\HasMeta;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@@ -35,8 +36,8 @@ class CartItem extends Model
'subtotal' => 'decimal:2',
'parameters' => 'array',
'meta' => 'array',
- 'from' => 'datetime',
- 'until' => 'datetime',
+ 'from' => HtmlDateTimeCast::class,
+ 'until' => HtmlDateTimeCast::class,
];
protected $appends = [
diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php
index 00ebb41..67e7dde 100644
--- a/src/Traits/MayBePoolProduct.php
+++ b/src/Traits/MayBePoolProduct.php
@@ -107,7 +107,7 @@ trait MayBePoolProduct
/**
* Claim stock for a pool product
- * This will claim stock from the available single items
+ * This will claim stock from the available single items, respecting the pricing strategy
*
* @param int $quantity Number of pool items to claim
* @param mixed $reference Reference model
@@ -134,13 +134,28 @@ trait MayBePoolProduct
throw new \Exception('Pool product has no single items to claim');
}
- // Get available single items for the period
+ // Get pricing strategy
+ $strategy = $this->getPricingStrategy();
+
+ // Build list of available single items with their prices
$availableItems = [];
foreach ($singleItems as $item) {
if ($item->isAvailableForBooking($from, $until, 1)) {
- $availableItems[] = $item;
+ // Get the price for this item
+ $price = $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
+
+ // If item has no price, use pool's fallback price
+ if ($price === null && $this->hasPrice()) {
+ $price = $this->defaultPrice()->first()?->getCurrentPrice($this->isOnSale());
+ }
+
+ $availableItems[] = [
+ 'item' => $item,
+ 'price' => $price ?? PHP_FLOAT_MAX, // Items without prices go last
+ ];
}
+ // Early exit if we have enough
if (count($availableItems) >= $quantity) {
break;
}
@@ -150,9 +165,19 @@ trait MayBePoolProduct
throw new \Exception("Only " . count($availableItems) . " items available, but {$quantity} requested");
}
- // Claim stock from each selected single item
+ // Sort by pricing strategy
+ usort($availableItems, function ($a, $b) use ($strategy) {
+ return match ($strategy) {
+ \Blax\Shop\Enums\PricingStrategy::LOWEST => $a['price'] <=> $b['price'],
+ \Blax\Shop\Enums\PricingStrategy::HIGHEST => $b['price'] <=> $a['price'],
+ \Blax\Shop\Enums\PricingStrategy::AVERAGE => 0, // Keep original order for average
+ };
+ });
+
+ // Claim stock from selected items in order
$claimedItems = [];
- foreach (array_slice($availableItems, 0, $quantity) as $item) {
+ foreach (array_slice($availableItems, 0, $quantity) as $itemData) {
+ $item = $itemData['item'];
$item->claimStock(1, $reference, $from, $until, $note);
$claimedItems[] = $item;
}
diff --git a/tests/Feature/PoolClaimingPriorityTest.php b/tests/Feature/PoolClaimingPriorityTest.php
new file mode 100644
index 0000000..0901677
--- /dev/null
+++ b/tests/Feature/PoolClaimingPriorityTest.php
@@ -0,0 +1,228 @@
+create([
+ 'name' => 'Parking Pool',
+ 'type' => ProductType::POOL,
+ 'manage_stock' => false,
+ ]);
+
+ // Set pool pricing strategy to LOWEST
+ $pool->setPoolPricingStrategy(PricingStrategy::LOWEST);
+
+ // Create fallback price on pool
+ ProductPrice::factory()->create([
+ 'purchasable_id' => $pool->id,
+ 'purchasable_type' => Product::class,
+ 'type' => PriceType::RECURRING,
+ 'unit_amount' => 500, // $5.00 fallback
+ 'is_default' => true,
+ ]);
+
+ // Create single parking spots with different prices
+ $spot1 = Product::factory()->create([
+ 'name' => 'Parking Spot 1',
+ 'type' => ProductType::BOOKING,
+ 'manage_stock' => true,
+ ]);
+ $spot1->increaseStock(1);
+
+ ProductPrice::factory()->create([
+ 'purchasable_id' => $spot1->id,
+ 'purchasable_type' => Product::class,
+ 'type' => PriceType::RECURRING,
+ 'unit_amount' => 500, // $5.00
+ 'is_default' => true,
+ ]);
+
+ $spot2 = Product::factory()->create([
+ 'name' => 'Parking Spot 2',
+ 'type' => ProductType::BOOKING,
+ 'manage_stock' => true,
+ ]);
+ $spot2->increaseStock(1);
+
+ ProductPrice::factory()->create([
+ 'purchasable_id' => $spot2->id,
+ 'purchasable_type' => Product::class,
+ 'type' => PriceType::RECURRING,
+ 'unit_amount' => 1000, // $10.00 (most expensive)
+ 'is_default' => true,
+ ]);
+
+ $spot3 = Product::factory()->create([
+ 'name' => 'Parking Spot 3',
+ 'type' => ProductType::BOOKING,
+ 'manage_stock' => true,
+ ]);
+ $spot3->increaseStock(1);
+
+ // Spot 3 has NO price - should use pool fallback (500)
+
+ // Attach spots to pool
+ $pool->attachSingleItems([$spot1->id, $spot2->id, $spot3->id]);
+
+ // Create cart and claim 3 spots
+ $cart = Cart::factory()->create();
+ $from = Carbon::now()->addDays(1);
+ $until = Carbon::now()->addDays(3);
+
+ $claimedItems = $pool->claimPoolStock(3, $cart, $from, $until);
+
+ // Should claim in order: spot1 (500), spot3 (500 fallback), spot2 (1000)
+ $this->assertCount(3, $claimedItems);
+
+ // Extract IDs for easier comparison
+ $claimedIds = array_map(fn($item) => $item->id, $claimedItems);
+
+ // First two should be spot1 and spot3 (both $5.00) in some order
+ $this->assertContains($spot1->id, [$claimedIds[0], $claimedIds[1]]);
+ $this->assertContains($spot3->id, [$claimedIds[0], $claimedIds[1]]);
+
+ // Last should be spot2 (most expensive)
+ $this->assertEquals($spot2->id, $claimedIds[2]);
+ }
+
+ /** @test */
+ public function it_claims_highest_priced_items_first_with_highest_strategy()
+ {
+ // Create pool product
+ $pool = Product::factory()->create([
+ 'name' => 'Premium Parking Pool',
+ 'type' => ProductType::POOL,
+ 'manage_stock' => false,
+ ]);
+
+ // Set pool pricing strategy to HIGHEST
+ $pool->setPoolPricingStrategy(PricingStrategy::HIGHEST);
+
+ // Create fallback price on pool
+ ProductPrice::factory()->create([
+ 'purchasable_id' => $pool->id,
+ 'purchasable_type' => Product::class,
+ 'type' => PriceType::RECURRING,
+ 'unit_amount' => 500,
+ 'is_default' => true,
+ ]);
+
+ // Create spots with different prices
+ $cheapSpot = Product::factory()->create([
+ 'name' => 'Cheap Spot',
+ 'type' => ProductType::BOOKING,
+ 'manage_stock' => true,
+ ]);
+ $cheapSpot->increaseStock(1);
+
+ ProductPrice::factory()->create([
+ 'purchasable_id' => $cheapSpot->id,
+ 'purchasable_type' => Product::class,
+ 'type' => PriceType::RECURRING,
+ 'unit_amount' => 300, // Cheapest
+ 'is_default' => true,
+ ]);
+
+ $expensiveSpot = Product::factory()->create([
+ 'name' => 'Expensive Spot',
+ 'type' => ProductType::BOOKING,
+ 'manage_stock' => true,
+ ]);
+ $expensiveSpot->increaseStock(1);
+
+ ProductPrice::factory()->create([
+ 'purchasable_id' => $expensiveSpot->id,
+ 'purchasable_type' => Product::class,
+ 'type' => PriceType::RECURRING,
+ 'unit_amount' => 1500, // Most expensive
+ 'is_default' => true,
+ ]);
+
+ // Attach to pool
+ $pool->attachSingleItems([$cheapSpot->id, $expensiveSpot->id]);
+
+ // Claim 2 spots
+ $cart = Cart::factory()->create();
+ $from = Carbon::now()->addDays(1);
+ $until = Carbon::now()->addDays(3);
+
+ $claimedItems = $pool->claimPoolStock(2, $cart, $from, $until);
+
+ // Should claim expensive first, then cheap
+ $this->assertEquals($expensiveSpot->id, $claimedItems[0]->id);
+ $this->assertEquals($cheapSpot->id, $claimedItems[1]->id);
+ }
+
+ /** @test */
+ public function it_uses_fallback_price_for_items_without_price()
+ {
+ $pool = Product::factory()->create([
+ 'name' => 'Parking Pool',
+ 'type' => ProductType::POOL,
+ 'manage_stock' => false,
+ ]);
+
+ $pool->setPoolPricingStrategy(PricingStrategy::LOWEST);
+
+ // Pool fallback price
+ ProductPrice::factory()->create([
+ 'purchasable_id' => $pool->id,
+ 'purchasable_type' => Product::class,
+ 'type' => PriceType::RECURRING,
+ 'unit_amount' => 500,
+ 'is_default' => true,
+ ]);
+
+ // Spot with specific price (higher than fallback)
+ $pricedSpot = Product::factory()->create([
+ 'name' => 'Priced Spot',
+ 'type' => ProductType::BOOKING,
+ 'manage_stock' => true,
+ ]);
+ $pricedSpot->increaseStock(1);
+
+ ProductPrice::factory()->create([
+ 'purchasable_id' => $pricedSpot->id,
+ 'purchasable_type' => Product::class,
+ 'type' => PriceType::RECURRING,
+ 'unit_amount' => 1000,
+ 'is_default' => true,
+ ]);
+
+ // Spot without price (uses fallback)
+ $unpricedSpot = Product::factory()->create([
+ 'name' => 'Unpriced Spot',
+ 'type' => ProductType::BOOKING,
+ 'manage_stock' => true,
+ ]);
+ $unpricedSpot->increaseStock(1);
+ // No price created for this spot
+
+ $pool->attachSingleItems([$pricedSpot->id, $unpricedSpot->id]);
+
+ // Claim both
+ $cart = Cart::factory()->create();
+ $from = Carbon::now()->addDays(1);
+ $until = Carbon::now()->addDays(3);
+
+ $claimedItems = $pool->claimPoolStock(2, $cart, $from, $until);
+
+ // Unpriced spot (using fallback 500) should be claimed first
+ $this->assertEquals($unpricedSpot->id, $claimedItems[0]->id);
+ $this->assertEquals($pricedSpot->id, $claimedItems[1]->id);
+ }
+}
diff --git a/tests/Unit/HtmlDateTimeCastTest.php b/tests/Unit/HtmlDateTimeCastTest.php
new file mode 100644
index 0000000..6e83dc6
--- /dev/null
+++ b/tests/Unit/HtmlDateTimeCastTest.php
@@ -0,0 +1,155 @@
+create();
+ $date = Carbon::parse('2025-12-25 14:30:00');
+
+ $cart->from_date = $date;
+ $cart->save();
+
+ // Reload from database
+ $cart->refresh();
+
+ // Should return Carbon instance
+ $this->assertInstanceOf(Carbon::class, $cart->from_date);
+ $this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s'));
+ }
+
+ /** @test */
+ public function it_accepts_datetime_interface_and_stores_as_timestamp()
+ {
+ $cart = Cart::factory()->create();
+ $date = new \DateTime('2025-12-25 14:30:00');
+
+ $cart->from_date = $date;
+ $cart->save();
+
+ $cart->refresh();
+
+ $this->assertInstanceOf(Carbon::class, $cart->from_date);
+ $this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s'));
+ }
+
+ /** @test */
+ public function it_accepts_string_and_stores_as_timestamp()
+ {
+ $cart = Cart::factory()->create();
+
+ // Standard datetime string
+ $cart->from_date = '2025-12-25 14:30:00';
+ $cart->save();
+
+ $cart->refresh();
+
+ $this->assertInstanceOf(Carbon::class, $cart->from_date);
+ $this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s'));
+ }
+
+ /** @test */
+ public function it_accepts_html5_datetime_local_format()
+ {
+ $cart = Cart::factory()->create();
+
+ // HTML5 datetime-local format (YYYY-MM-DDTHH:MM)
+ $cart->from_date = '2025-12-25T14:30';
+ $cart->save();
+
+ $cart->refresh();
+
+ $this->assertInstanceOf(Carbon::class, $cart->from_date);
+ $this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s'));
+ }
+
+ /** @test */
+ public function it_can_format_for_html5_input()
+ {
+ $cart = Cart::factory()->create();
+ $cart->from_date = Carbon::parse('2025-12-25 14:30:00');
+ $cart->save();
+
+ $cart->refresh();
+
+ // Can format for HTML5 datetime-local input
+ $htmlFormat = $cart->from_date->format('Y-m-d\TH:i');
+ $this->assertEquals('2025-12-25T14:30', $htmlFormat);
+ }
+
+ /** @test */
+ public function it_handles_null_values()
+ {
+ $cart = Cart::factory()->create();
+ $cart->from_date = null;
+ $cart->save();
+
+ $cart->refresh();
+
+ $this->assertNull($cart->from_date);
+ }
+
+ /** @test */
+ public function it_works_with_cart_items()
+ {
+ $product = Product::factory()->create();
+ $cart = Cart::factory()->create();
+
+ $item = $cart->items()->create([
+ 'purchasable_id' => $product->id,
+ 'purchasable_type' => get_class($product),
+ 'quantity' => 1,
+ 'price' => 100,
+ 'subtotal' => 100,
+ 'from' => '2025-12-25T14:30',
+ 'until' => '2025-12-27T10:00',
+ ]);
+
+ $item->refresh();
+
+ $this->assertInstanceOf(Carbon::class, $item->from);
+ $this->assertInstanceOf(Carbon::class, $item->until);
+ $this->assertEquals('2025-12-25T14:30', $item->from->format('Y-m-d\TH:i'));
+ $this->assertEquals('2025-12-27T10:00', $item->until->format('Y-m-d\TH:i'));
+ }
+
+ /** @test */
+ public function it_accepts_unix_timestamp()
+ {
+ $cart = Cart::factory()->create();
+ $timestamp = Carbon::parse('2025-12-25 14:30:00')->timestamp;
+
+ $cart->from_date = $timestamp;
+ $cart->save();
+
+ $cart->refresh();
+
+ $this->assertInstanceOf(Carbon::class, $cart->from_date);
+ $this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s'));
+ }
+
+ /** @test */
+ public function it_maintains_carbon_methods()
+ {
+ $cart = Cart::factory()->create();
+ $cart->from_date = Carbon::parse('2025-12-25 14:30:00');
+ $cart->save();
+
+ $cart->refresh();
+
+ // All Carbon methods should be available
+ $this->assertTrue($cart->from_date->isAfter(Carbon::parse('2025-12-24')));
+ $this->assertTrue($cart->from_date->isBefore(Carbon::parse('2025-12-26')));
+ $this->assertEquals('December', $cart->from_date->format('F'));
+ $this->assertEquals('2025-12-25', $cart->from_date->toDateString());
+ }
+}