From 70adf0b0c6d000b913aab51b38b29541acee25ce Mon Sep 17 00:00:00 2001 From: a6a2f5842 Date: Fri, 26 Dec 2025 08:42:59 +0100 Subject: [PATCH] BFI cart availability --- src/Models/Cart.php | 91 ++++++++++--- src/Models/CartItem.php | 37 +++--- src/Models/Product.php | 14 +- src/Traits/MayBePoolProduct.php | 18 ++- .../CartItemAvailabilityValidationTest.php | 122 ++++++++++++------ tests/Feature/CartServiceBookingTest.php | 103 ++++++--------- 6 files changed, 230 insertions(+), 155 deletions(-) diff --git a/src/Models/Cart.php b/src/Models/Cart.php index f04bd83..964a50a 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -857,22 +857,6 @@ class Cart extends Model throw new CartableInterfaceException(); } - // Extract dates from parameters if not provided directly - if (!$from && isset($parameters['from'])) { - $from = is_string($parameters['from']) ? Carbon::parse($parameters['from']) : $parameters['from']; - } - if (!$until && isset($parameters['until'])) { - $until = is_string($parameters['until']) ? Carbon::parse($parameters['until']) : $parameters['until']; - } - - // Fallback to cart dates if no dates provided - if (!$from && $this->from) { - $from = $this->from; - } - if (!$until && $this->until) { - $until = $this->until; - } - if ($cartable instanceof Product) { $is_pool = $cartable->isPool(); $is_booking = $cartable->isBooking(); @@ -884,6 +868,24 @@ class Cart extends Model $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']; + } + if (!$until && isset($parameters['until'])) { + $until = is_string($parameters['until']) ? Carbon::parse($parameters['until']) : $parameters['until']; + } + + // Fallback to cart dates if no dates provided + if (!$from && $this->from) { + $from = $this->from; + } + if (!$until && $this->until) { + $until = $this->until; + } + } + // For pool products with quantity > 1, add them one at a time to get progressive pricing if ($is_pool && $quantity > 1) { @@ -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); } } } diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index 62b4909..46da253 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -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)) { - return false; - } + 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) diff --git a/src/Models/Product.php b/src/Models/Product.php index 1c442a4..06487c2 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -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); }); }); }) diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php index 65da56a..a0a4f3a 100644 --- a/src/Traits/MayBePoolProduct.php +++ b/src/Traits/MayBePoolProduct.php @@ -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); }); }); }) diff --git a/tests/Feature/CartItemAvailabilityValidationTest.php b/tests/Feature/CartItemAvailabilityValidationTest.php index 54d3aae..ec93e6b 100644 --- a/tests/Feature/CartItemAvailabilityValidationTest.php +++ b/tests/Feature/CartItemAvailabilityValidationTest.php @@ -45,38 +45,26 @@ class CartItemAvailabilityValidationTest extends TestCase */ protected function createPoolWithLimitedSingles(int $numSingles = 3): Product { - $pool = Product::factory()->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 = 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()->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, - ]); + $single = Product::factory() + ->withStocks(1) + ->withPrices(1, 5000) + ->create([ + 'name' => "Single {$i}", + 'type' => ProductType::BOOKING, + 'manage_stock' => 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)'); } } diff --git a/tests/Feature/CartServiceBookingTest.php b/tests/Feature/CartServiceBookingTest.php index 2aeab1a..dadbb38 100644 --- a/tests/Feature/CartServiceBookingTest.php +++ b/tests/Feature/CartServiceBookingTest.php @@ -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([ - '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, - ]); + $this->bookingProduct = Product::factory() + ->withStocks(10) + ->withPrices(1, 10000) + ->create([ + 'name' => 'Hotel Room', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); // Create pool product with single items - $this->poolProduct = Product::factory()->create([ - 'name' => 'Parking Spaces', - 'type' => ProductType::POOL, - 'manage_stock' => false, - ]); + $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([ - 'name' => 'Parking Spot 1', - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - ]); - $this->singleItem1->increaseStock(1); + $this->singleItem1 = Product::factory() + ->withStocks(1) + ->create([ + 'name' => 'Parking Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); - $this->singleItem2 = Product::factory()->create([ - 'name' => 'Parking Spot 2', - 'type' => ProductType::BOOKING, - 'manage_stock' => true, - ]); - $this->singleItem2->increaseStock(1); + $this->singleItem2 = Product::factory() + ->withStocks(1) + ->create([ + 'name' => 'Parking Spot 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); $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]