BFI cart availability

This commit is contained in:
a6a2f5842 2025-12-26 08:42:59 +01:00
parent 9e7812c70e
commit 70adf0b0c6
6 changed files with 230 additions and 155 deletions

View File

@ -857,6 +857,18 @@ class Cart extends Model
throw new CartableInterfaceException();
}
if ($cartable instanceof Product) {
$is_pool = $cartable->isPool();
$is_booking = $cartable->isBooking();
} elseif (
$cartable instanceof ProductPrice
&& $cartable->purchasable instanceof Product
) {
$is_pool = $cartable->purchasable->isPool();
$is_booking = $cartable->purchasable->isBooking();
}
if ($is_booking) {
// Extract dates from parameters if not provided directly
if (!$from && isset($parameters['from'])) {
$from = is_string($parameters['from']) ? Carbon::parse($parameters['from']) : $parameters['from'];
@ -872,16 +884,6 @@ class Cart extends Model
if (!$until && $this->until) {
$until = $this->until;
}
if ($cartable instanceof Product) {
$is_pool = $cartable->isPool();
$is_booking = $cartable->isBooking();
} elseif (
$cartable instanceof ProductPrice
&& $cartable->purchasable instanceof Product
) {
$is_pool = $cartable->purchasable->isPool();
$is_booking = $cartable->purchasable->isBooking();
}
@ -1206,8 +1208,8 @@ class Cart extends Model
'unit_amount' => $unitAmount, // Base price for 1 quantity, 1 day (in cents)
'subtotal' => $totalPrice, // Total for all units
'parameters' => $parameters,
'from' => $from,
'until' => $until,
'from' => ($is_booking) ? $from : null,
'until' => ($is_booking) ? $until : null,
]);
// For pool products, store which single item is being used in meta
@ -1420,6 +1422,55 @@ class Cart extends Model
return true;
}
/**
* Convert this cart into purchases (atomic checkout).
*
* This method performs an in-database checkout and is intended to be safe against
* concurrent requests. It does not take payment; it turns each cart item into a
* purchase and (where applicable) claims stock for the booked timespan.
*
* Step-by-step:
* 1) Start a database transaction so the entire checkout is atomic.
* 2) Lock the cart row (`lockForUpdate`) to prevent concurrent checkouts of the same cart.
* 3) Validate the cart via `validateForCheckout()`:
* - cart is not already converted
* - cart is not empty
* - all items have required information (e.g. booking dates)
* - stock is available for each item (including booking/pool checks when dates exist)
* 4) Load cart items with their `purchasable` models.
* 5) For each cart item:
* a) Resolve the purchasable product and lock it (when supported) to reduce stock race conditions.
* b) Determine quantity.
* c) Resolve booking dates:
* - Prefer the cart-item `from`/`until` columns.
* - Fallback to legacy `$item->parameters['from'|'until']` for BOOKING/POOL items.
* - Parse string dates into Carbon instances.
* d) If the product is a pool:
* - If the pool contains booking single items, a timespan is required.
* - When a timespan exists and booking singles are used, claim stock:
* - Use a pre-allocated single item from item meta (`allocated_single_item_id`) when present.
* - Otherwise call the pool stock claiming logic (`claimPoolStock`).
* - Persist claimed single-item IDs into cart item meta (`claimed_single_items`).
* e) If the product is a non-pool booking product, require a timespan.
* f) Create a purchase via `$this->customer->purchase(...)` using the product's first price,
* passing quantity and booking dates.
* g) Link the purchase back to the cart (`cart_id`) and link the cart item to the purchase (`purchase_id`).
* 6) Mark the cart as converted by setting `converted_at`.
* 7) Commit the transaction and return the updated cart instance.
*
* Side effects:
* - Creates one purchase record per cart item.
* - Claims stock for booking/pool items when dates are provided and required.
* - Updates cart items with `purchase_id` and the cart with `converted_at`.
*
* @return static The converted cart (fresh state within the transaction scope).
*
* @throws \Blax\Shop\Exceptions\CartAlreadyConvertedException
* @throws \Blax\Shop\Exceptions\CartEmptyException
* @throws \Blax\Shop\Exceptions\CartItemMissingInformationException
* @throws \Blax\Shop\Exceptions\NotEnoughStockException
* @throws \Throwable For any other unexpected failures during checkout/stock claiming.
*/
public function checkout(): static
{
return DB::transaction(function () {
@ -1456,10 +1507,10 @@ class Cart extends Model
// Convert to Carbon instances if they're strings
if ($from && is_string($from)) {
$from = \Carbon\Carbon::parse($from);
$from = Carbon::parse($from);
}
if ($until && is_string($until)) {
$until = \Carbon\Carbon::parse($until);
$until = Carbon::parse($until);
}
}
}

View File

@ -169,29 +169,28 @@ class CartItem extends Model
*/
public function getIsReadyToCheckoutAttribute(): bool
{
// Only check if purchasable is a Product
if ($this->purchasable_type !== config('shop.models.product', Product::class)) {
return true; // Non-product items are always ready
}
$product = $this->purchasable;
$product = $this->purchasable instanceof ProductPrice
? $this->purchasable->purchasable
: $this->purchasable;
if (!$product) {
return false;
}
// Check if item has a valid price (null or <= 0 means unavailable)
if ($this->price === null || $this->price <= 0) {
// Check if item has a valid price
if ($this->price === null) {
return false;
}
// Note: Pool items don't require pre-allocation to be ready for checkout.
// The checkout process can allocate singles on-the-fly via claimPoolStock().
// The price check above is sufficient - if price is null, item is unavailable.
$is_booking = $product->isBooking();
$is_pool = $product->isPool();
// Check if dates are required (for booking products or pools with booking items)
$requiresDates = $product->isBooking() ||
($product->isPool() && $product->hasBookingSingleItems());
$requiresDates = $is_booking ||
($is_pool && $product->hasBookingSingleItems());
if ($requiresDates) {
// Get effective dates (item-specific or cart fallback)
@ -209,23 +208,25 @@ class CartItem extends Model
}
// Check stock availability for the booking period
if ($product->isBooking()) {
if (!$product->isAvailableForBooking($effectiveFrom, $effectiveUntil, $this->quantity)) {
if (
$is_booking
&& !$product->isAvailableForBooking($effectiveFrom, $effectiveUntil, $this->quantity)
) {
return false;
}
}
// Check pool availability with dates
if ($product->isPool()) {
if ($is_pool) {
$available = $product->getPoolMaxQuantity($effectiveFrom, $effectiveUntil);
// Get current quantity in cart for this product (excluding this item)
// Get quantity in cart for this product from items BEFORE this one (by id order)
// This ensures the first N items up to available capacity are marked as ready
$cartQuantity = 0;
if ($this->cart) {
$cartQuantity = $this->cart->items()
->where('purchasable_id', $product->getKey())
->where('purchasable_type', get_class($product))
->where('id', '!=', $this->id)
->where('id', '<', $this->id)
->sum('quantity');
}
@ -235,7 +236,7 @@ class CartItem extends Model
}
} else {
// For non-booking products, just check stock availability
if ($product->isPool()) {
if ($is_pool) {
$available = $product->getPoolMaxQuantity();
// Get current quantity in cart for this product (excluding this item)

View File

@ -342,21 +342,23 @@ class Product extends Model implements Purchasable, Cartable
->where('status', StockStatus::PENDING->value)
->where(function ($query) use ($from, $until) {
$query->where(function ($q) use ($from, $until) {
// Claim starts during the requested period
$q->whereBetween('claimed_from', [$from, $until]);
// Claim starts during the requested period (exclusive end for hotel-style bookings)
$q->where('claimed_from', '>=', $from)
->where('claimed_from', '<', $until);
})->orWhere(function ($q) use ($from, $until) {
// Claim ends during the requested period
$q->whereBetween('expires_at', [$from, $until]);
// Claim ends during the requested period (exclusive start - checkout day = checkin day is OK)
$q->where('expires_at', '>', $from)
->where('expires_at', '<=', $until);
})->orWhere(function ($q) use ($from, $until) {
// Claim encompasses the entire requested period
$q->where('claimed_from', '<=', $from)
->where('expires_at', '>=', $until);
->where('expires_at', '>', $until);
})->orWhere(function ($q) use ($from, $until) {
// Claim without claimed_from (immediately claimed)
$q->whereNull('claimed_from')
->where(function ($subQ) use ($from, $until) {
$subQ->whereNull('expires_at')
->orWhere('expires_at', '>=', $from);
->orWhere('expires_at', '>', $from);
});
});
})

View File

@ -296,21 +296,27 @@ trait MayBePoolProduct
} else {
// Calculate overlapping claims for this specific period
$overlappingClaims = $item->stocks()
->where('type', \Blax\Shop\Enums\StockType::CLAIMED->value)
->where('status', \Blax\Shop\Enums\StockStatus::PENDING->value)
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
->where(function ($query) use ($from, $until) {
$query->where(function ($q) use ($from, $until) {
$q->whereBetween('claimed_from', [$from, $until]);
// Claim starts during the requested period (exclusive end)
$q->where('claimed_from', '>=', $from)
->where('claimed_from', '<', $until);
})->orWhere(function ($q) use ($from, $until) {
$q->whereBetween('expires_at', [$from, $until]);
// Claim ends during the requested period (exclusive start - checkout day = checkin day is OK)
$q->where('expires_at', '>', $from)
->where('expires_at', '<=', $until);
})->orWhere(function ($q) use ($from, $until) {
// Claim encompasses the entire requested period
$q->where('claimed_from', '<=', $from)
->where('expires_at', '>=', $until);
->where('expires_at', '>', $until);
})->orWhere(function ($q) use ($from, $until) {
// Claim without claimed_from (immediately claimed)
$q->whereNull('claimed_from')
->where(function ($subQ) use ($from, $until) {
$subQ->whereNull('expires_at')
->orWhere('expires_at', '>=', $from);
->orWhere('expires_at', '>', $from);
});
});
})

View File

@ -45,38 +45,26 @@ class CartItemAvailabilityValidationTest extends TestCase
*/
protected function createPoolWithLimitedSingles(int $numSingles = 3): Product
{
$pool = Product::factory()->create([
$pool = Product::factory()
->withPrices(1, 5000)
->create([
'name' => 'Limited Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
ProductPrice::factory()->create([
'purchasable_id' => $pool->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$pool->setPoolPricingStrategy('lowest');
// Create singles with 1 stock each
for ($i = 1; $i <= $numSingles; $i++) {
$single = Product::factory()->create([
$single = Product::factory()
->withStocks(1)
->withPrices(1, 5000)
->create([
'name' => "Single {$i}",
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$single->increaseStock(1);
ProductPrice::factory()->create([
'purchasable_id' => $single->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$pool->attachSingleItems([$single->id]);
}
@ -461,29 +449,79 @@ class CartItemAvailabilityValidationTest extends TestCase
}
#[Test]
public function price_zero_is_treated_as_unavailable()
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(2);
$until = now()->addDays(4);
$this->assertEquals(3, $pool->getPoolMaxQuantity($from, $until), 'No singles should be available after booking');
$this->cart->addToCart($pool, 3, [], $from, $until);
$cart = $this->user->currentCart();
$cart->addToCart($pool, 2, [], $from, $until);
// Set price to 0 (simulating an old bug where 0 was used instead of null)
$item = $this->cart->items()->first();
$item->update(['price' => 0, 'subtotal' => 0]);
$item->refresh();
foreach ($cart->items as $item) {
$this->assertTrue($item->is_ready_to_checkout, 'Item should be ready before booking');
}
// Item should NOT be ready for checkout
$this->assertFalse($item->is_ready_to_checkout, 'Item with price 0 should not be ready');
$this->assertTrue($cart->is_ready_to_checkout, 'Cart should be ready before booking');
$cart->checkout();
// requiredAdjustments should show price as unavailable
$adjustments = $item->requiredAdjustments();
$this->assertArrayHasKey('price', $adjustments);
$this->assertEquals('unavailable', $adjustments['price']);
$this->assertEquals(1, $pool->getPoolMaxQuantity($from, $until), 'No singles should be available after booking');
// Cart should NOT be ready
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout);
$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)');
$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');
$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)');
}
}

View File

@ -4,6 +4,7 @@ namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Facades\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
@ -33,49 +34,40 @@ class CartServiceBookingTest extends TestCase
$this->actingAs($this->user);
// Create booking product
$this->bookingProduct = Product::factory()->create([
$this->bookingProduct = Product::factory()
->withStocks(10)
->withPrices(1, 10000)
->create([
'name' => 'Hotel Room',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$this->bookingProduct->increaseStock(10);
$this->bookingPrice = ProductPrice::factory()->create([
'purchasable_id' => $this->bookingProduct->id,
'purchasable_type' => Product::class,
'unit_amount' => 10000, // $100.00
'currency' => 'USD',
'is_default' => true,
]);
// Create pool product with single items
$this->poolProduct = Product::factory()->create([
$this->poolProduct = Product::factory()
->withPrices(1, 2000)
->create([
'name' => 'Parking Spaces',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$this->poolPrice = ProductPrice::factory()->create([
'purchasable_id' => $this->poolProduct->id,
'purchasable_type' => Product::class,
'unit_amount' => 2000, // $20.00
'currency' => 'USD',
'is_default' => true,
]);
$this->singleItem1 = Product::factory()->create([
$this->singleItem1 = Product::factory()
->withStocks(1)
->create([
'name' => 'Parking Spot 1',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$this->singleItem1->increaseStock(1);
$this->singleItem2 = Product::factory()->create([
$this->singleItem2 = Product::factory()
->withStocks(1)
->create([
'name' => 'Parking Spot 2',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$this->singleItem2->increaseStock(1);
$this->poolProduct->productRelations()->attach($this->singleItem1->id, [
'type' => ProductRelationType::SINGLE->value,
@ -89,40 +81,30 @@ class CartServiceBookingTest extends TestCase
public function validate_bookings_returns_error_for_booking_product_without_timespan()
{
$cart = $this->user->currentCart();
// Add booking product without timespan
$cart->items()->create([
'purchasable_id' => $this->bookingProduct->id,
'purchasable_type' => Product::class,
'quantity' => 1,
'price' => 100.00,
]);
$cart->addToCart($this->bookingProduct, 1);
$errors = Cart::validateBookings();
$this->assertNotEmpty($errors);
$this->assertStringContainsString('requires a timespan', $errors[0]);
$this->assertStringContainsString('Hotel Room', $errors[0]);
$this->assertEquals(10000, $cart->getTotal());
}
#[Test]
public function validate_bookings_returns_error_for_pool_product_without_timespan_when_single_items_are_bookings()
{
$cart = $this->user->currentCart();
// Add pool product without timespan
$cart->items()->create([
'purchasable_id' => $this->poolProduct->id,
'purchasable_type' => Product::class,
'quantity' => 1,
'price' => 20.00,
]);
$cart->addToCart($this->poolProduct, 1);
$errors = Cart::validateBookings();
$this->assertNotEmpty($errors);
$this->assertStringContainsString('requires either a timespan', $errors[0]);
$this->assertStringContainsString('Parking Spaces', $errors[0]);
$this->assertEquals(2000, $cart->getTotal());
}
#[Test]
@ -135,20 +117,15 @@ class CartServiceBookingTest extends TestCase
// Book all stock first
$this->bookingProduct->claimStock(10, null, $from, $until);
// Try to add more than available
$cart->items()->create([
'purchasable_id' => $this->bookingProduct->id,
'purchasable_type' => Product::class,
'quantity' => 5,
'price' => 100.00,
'from' => $from,
'until' => $until,
]);
$this->expectException(NotEnoughStockException::class);
$cart->addToCart($this->bookingProduct, 5, [], $from, $until);
$errors = Cart::validateBookings();
$this->assertNotEmpty($errors);
$this->assertStringContainsString('not available for the selected period', $errors[0]);
$this->assertEquals(0, $cart->getTotal());
}
#[Test]