BFI cart availability
This commit is contained in:
parent
9e7812c70e
commit
70adf0b0c6
|
|
@ -857,6 +857,18 @@ class Cart extends Model
|
||||||
throw new CartableInterfaceException();
|
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
|
// Extract dates from parameters if not provided directly
|
||||||
if (!$from && isset($parameters['from'])) {
|
if (!$from && isset($parameters['from'])) {
|
||||||
$from = is_string($parameters['from']) ? Carbon::parse($parameters['from']) : $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) {
|
if (!$until && $this->until) {
|
||||||
$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)
|
'unit_amount' => $unitAmount, // Base price for 1 quantity, 1 day (in cents)
|
||||||
'subtotal' => $totalPrice, // Total for all units
|
'subtotal' => $totalPrice, // Total for all units
|
||||||
'parameters' => $parameters,
|
'parameters' => $parameters,
|
||||||
'from' => $from,
|
'from' => ($is_booking) ? $from : null,
|
||||||
'until' => $until,
|
'until' => ($is_booking) ? $until : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// For pool products, store which single item is being used in meta
|
// For pool products, store which single item is being used in meta
|
||||||
|
|
@ -1420,6 +1422,55 @@ class Cart extends Model
|
||||||
return true;
|
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
|
public function checkout(): static
|
||||||
{
|
{
|
||||||
return DB::transaction(function () {
|
return DB::transaction(function () {
|
||||||
|
|
@ -1456,10 +1507,10 @@ class Cart extends Model
|
||||||
|
|
||||||
// Convert to Carbon instances if they're strings
|
// Convert to Carbon instances if they're strings
|
||||||
if ($from && is_string($from)) {
|
if ($from && is_string($from)) {
|
||||||
$from = \Carbon\Carbon::parse($from);
|
$from = Carbon::parse($from);
|
||||||
}
|
}
|
||||||
if ($until && is_string($until)) {
|
if ($until && is_string($until)) {
|
||||||
$until = \Carbon\Carbon::parse($until);
|
$until = Carbon::parse($until);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,29 +169,28 @@ class CartItem extends Model
|
||||||
*/
|
*/
|
||||||
public function getIsReadyToCheckoutAttribute(): bool
|
public function getIsReadyToCheckoutAttribute(): bool
|
||||||
{
|
{
|
||||||
// Only check if purchasable is a Product
|
$product = $this->purchasable instanceof ProductPrice
|
||||||
if ($this->purchasable_type !== config('shop.models.product', Product::class)) {
|
? $this->purchasable->purchasable
|
||||||
return true; // Non-product items are always ready
|
: $this->purchasable;
|
||||||
}
|
|
||||||
|
|
||||||
$product = $this->purchasable;
|
|
||||||
|
|
||||||
if (!$product) {
|
if (!$product) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if item has a valid price (null or <= 0 means unavailable)
|
// Check if item has a valid price
|
||||||
if ($this->price === null || $this->price <= 0) {
|
if ($this->price === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Pool items don't require pre-allocation to be ready for checkout.
|
// 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 checkout process can allocate singles on-the-fly via claimPoolStock().
|
||||||
// The price check above is sufficient - if price is null, item is unavailable.
|
// 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)
|
// Check if dates are required (for booking products or pools with booking items)
|
||||||
$requiresDates = $product->isBooking() ||
|
$requiresDates = $is_booking ||
|
||||||
($product->isPool() && $product->hasBookingSingleItems());
|
($is_pool && $product->hasBookingSingleItems());
|
||||||
|
|
||||||
if ($requiresDates) {
|
if ($requiresDates) {
|
||||||
// Get effective dates (item-specific or cart fallback)
|
// Get effective dates (item-specific or cart fallback)
|
||||||
|
|
@ -209,23 +208,25 @@ class CartItem extends Model
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check stock availability for the booking period
|
// Check stock availability for the booking period
|
||||||
if ($product->isBooking()) {
|
if (
|
||||||
if (!$product->isAvailableForBooking($effectiveFrom, $effectiveUntil, $this->quantity)) {
|
$is_booking
|
||||||
|
&& !$product->isAvailableForBooking($effectiveFrom, $effectiveUntil, $this->quantity)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check pool availability with dates
|
// Check pool availability with dates
|
||||||
if ($product->isPool()) {
|
if ($is_pool) {
|
||||||
$available = $product->getPoolMaxQuantity($effectiveFrom, $effectiveUntil);
|
$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;
|
$cartQuantity = 0;
|
||||||
if ($this->cart) {
|
if ($this->cart) {
|
||||||
$cartQuantity = $this->cart->items()
|
$cartQuantity = $this->cart->items()
|
||||||
->where('purchasable_id', $product->getKey())
|
->where('purchasable_id', $product->getKey())
|
||||||
->where('purchasable_type', get_class($product))
|
->where('purchasable_type', get_class($product))
|
||||||
->where('id', '!=', $this->id)
|
->where('id', '<', $this->id)
|
||||||
->sum('quantity');
|
->sum('quantity');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,7 +236,7 @@ class CartItem extends Model
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For non-booking products, just check stock availability
|
// For non-booking products, just check stock availability
|
||||||
if ($product->isPool()) {
|
if ($is_pool) {
|
||||||
$available = $product->getPoolMaxQuantity();
|
$available = $product->getPoolMaxQuantity();
|
||||||
|
|
||||||
// Get current quantity in cart for this product (excluding this item)
|
// Get current quantity in cart for this product (excluding this item)
|
||||||
|
|
|
||||||
|
|
@ -342,21 +342,23 @@ class Product extends Model implements Purchasable, Cartable
|
||||||
->where('status', StockStatus::PENDING->value)
|
->where('status', StockStatus::PENDING->value)
|
||||||
->where(function ($query) use ($from, $until) {
|
->where(function ($query) use ($from, $until) {
|
||||||
$query->where(function ($q) use ($from, $until) {
|
$query->where(function ($q) use ($from, $until) {
|
||||||
// Claim starts during the requested period
|
// Claim starts during the requested period (exclusive end for hotel-style bookings)
|
||||||
$q->whereBetween('claimed_from', [$from, $until]);
|
$q->where('claimed_from', '>=', $from)
|
||||||
|
->where('claimed_from', '<', $until);
|
||||||
})->orWhere(function ($q) use ($from, $until) {
|
})->orWhere(function ($q) use ($from, $until) {
|
||||||
// Claim ends during the requested period
|
// Claim ends during the requested period (exclusive start - checkout day = checkin day is OK)
|
||||||
$q->whereBetween('expires_at', [$from, $until]);
|
$q->where('expires_at', '>', $from)
|
||||||
|
->where('expires_at', '<=', $until);
|
||||||
})->orWhere(function ($q) use ($from, $until) {
|
})->orWhere(function ($q) use ($from, $until) {
|
||||||
// Claim encompasses the entire requested period
|
// Claim encompasses the entire requested period
|
||||||
$q->where('claimed_from', '<=', $from)
|
$q->where('claimed_from', '<=', $from)
|
||||||
->where('expires_at', '>=', $until);
|
->where('expires_at', '>', $until);
|
||||||
})->orWhere(function ($q) use ($from, $until) {
|
})->orWhere(function ($q) use ($from, $until) {
|
||||||
// Claim without claimed_from (immediately claimed)
|
// Claim without claimed_from (immediately claimed)
|
||||||
$q->whereNull('claimed_from')
|
$q->whereNull('claimed_from')
|
||||||
->where(function ($subQ) use ($from, $until) {
|
->where(function ($subQ) use ($from, $until) {
|
||||||
$subQ->whereNull('expires_at')
|
$subQ->whereNull('expires_at')
|
||||||
->orWhere('expires_at', '>=', $from);
|
->orWhere('expires_at', '>', $from);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -296,21 +296,27 @@ trait MayBePoolProduct
|
||||||
} else {
|
} else {
|
||||||
// Calculate overlapping claims for this specific period
|
// Calculate overlapping claims for this specific period
|
||||||
$overlappingClaims = $item->stocks()
|
$overlappingClaims = $item->stocks()
|
||||||
->where('type', \Blax\Shop\Enums\StockType::CLAIMED->value)
|
->where('type', StockType::CLAIMED->value)
|
||||||
->where('status', \Blax\Shop\Enums\StockStatus::PENDING->value)
|
->where('status', StockStatus::PENDING->value)
|
||||||
->where(function ($query) use ($from, $until) {
|
->where(function ($query) use ($from, $until) {
|
||||||
$query->where(function ($q) 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) {
|
})->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) {
|
})->orWhere(function ($q) use ($from, $until) {
|
||||||
|
// Claim encompasses the entire requested period
|
||||||
$q->where('claimed_from', '<=', $from)
|
$q->where('claimed_from', '<=', $from)
|
||||||
->where('expires_at', '>=', $until);
|
->where('expires_at', '>', $until);
|
||||||
})->orWhere(function ($q) use ($from, $until) {
|
})->orWhere(function ($q) use ($from, $until) {
|
||||||
|
// Claim without claimed_from (immediately claimed)
|
||||||
$q->whereNull('claimed_from')
|
$q->whereNull('claimed_from')
|
||||||
->where(function ($subQ) use ($from, $until) {
|
->where(function ($subQ) use ($from, $until) {
|
||||||
$subQ->whereNull('expires_at')
|
$subQ->whereNull('expires_at')
|
||||||
->orWhere('expires_at', '>=', $from);
|
->orWhere('expires_at', '>', $from);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -45,38 +45,26 @@ class CartItemAvailabilityValidationTest extends TestCase
|
||||||
*/
|
*/
|
||||||
protected function createPoolWithLimitedSingles(int $numSingles = 3): Product
|
protected function createPoolWithLimitedSingles(int $numSingles = 3): Product
|
||||||
{
|
{
|
||||||
$pool = Product::factory()->create([
|
$pool = Product::factory()
|
||||||
|
->withPrices(1, 5000)
|
||||||
|
->create([
|
||||||
'name' => 'Limited Pool',
|
'name' => 'Limited Pool',
|
||||||
'type' => ProductType::POOL,
|
'type' => ProductType::POOL,
|
||||||
'manage_stock' => false,
|
'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');
|
$pool->setPoolPricingStrategy('lowest');
|
||||||
|
|
||||||
// Create singles with 1 stock each
|
// Create singles with 1 stock each
|
||||||
for ($i = 1; $i <= $numSingles; $i++) {
|
for ($i = 1; $i <= $numSingles; $i++) {
|
||||||
$single = Product::factory()->create([
|
$single = Product::factory()
|
||||||
|
->withStocks(1)
|
||||||
|
->withPrices(1, 5000)
|
||||||
|
->create([
|
||||||
'name' => "Single {$i}",
|
'name' => "Single {$i}",
|
||||||
'type' => ProductType::BOOKING,
|
'type' => ProductType::BOOKING,
|
||||||
'manage_stock' => true,
|
'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]);
|
$pool->attachSingleItems([$single->id]);
|
||||||
}
|
}
|
||||||
|
|
@ -461,29 +449,79 @@ class CartItemAvailabilityValidationTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[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);
|
$pool = $this->createPoolWithLimitedSingles(3);
|
||||||
|
|
||||||
$from = now()->addDays(1);
|
$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)
|
foreach ($cart->items as $item) {
|
||||||
$item = $this->cart->items()->first();
|
$this->assertTrue($item->is_ready_to_checkout, 'Item should be ready before booking');
|
||||||
$item->update(['price' => 0, 'subtotal' => 0]);
|
}
|
||||||
$item->refresh();
|
|
||||||
|
|
||||||
// Item should NOT be ready for checkout
|
$this->assertTrue($cart->is_ready_to_checkout, 'Cart should be ready before booking');
|
||||||
$this->assertFalse($item->is_ready_to_checkout, 'Item with price 0 should not be ready');
|
$cart->checkout();
|
||||||
|
|
||||||
// requiredAdjustments should show price as unavailable
|
$this->assertEquals(1, $pool->getPoolMaxQuantity($from, $until), 'No singles should be available after booking');
|
||||||
$adjustments = $item->requiredAdjustments();
|
|
||||||
$this->assertArrayHasKey('price', $adjustments);
|
|
||||||
$this->assertEquals('unavailable', $adjustments['price']);
|
|
||||||
|
|
||||||
// Cart should NOT be ready
|
$cart = $this->user->currentCart();
|
||||||
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout);
|
$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)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace Blax\Shop\Tests\Feature;
|
||||||
|
|
||||||
use Blax\Shop\Enums\ProductRelationType;
|
use Blax\Shop\Enums\ProductRelationType;
|
||||||
use Blax\Shop\Enums\ProductType;
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||||
use Blax\Shop\Facades\Cart;
|
use Blax\Shop\Facades\Cart;
|
||||||
use Blax\Shop\Models\Product;
|
use Blax\Shop\Models\Product;
|
||||||
use Blax\Shop\Models\ProductPrice;
|
use Blax\Shop\Models\ProductPrice;
|
||||||
|
|
@ -33,49 +34,40 @@ class CartServiceBookingTest extends TestCase
|
||||||
$this->actingAs($this->user);
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
// Create booking product
|
// Create booking product
|
||||||
$this->bookingProduct = Product::factory()->create([
|
$this->bookingProduct = Product::factory()
|
||||||
|
->withStocks(10)
|
||||||
|
->withPrices(1, 10000)
|
||||||
|
->create([
|
||||||
'name' => 'Hotel Room',
|
'name' => 'Hotel Room',
|
||||||
'type' => ProductType::BOOKING,
|
'type' => ProductType::BOOKING,
|
||||||
'manage_stock' => true,
|
'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
|
// Create pool product with single items
|
||||||
$this->poolProduct = Product::factory()->create([
|
$this->poolProduct = Product::factory()
|
||||||
|
->withPrices(1, 2000)
|
||||||
|
->create([
|
||||||
'name' => 'Parking Spaces',
|
'name' => 'Parking Spaces',
|
||||||
'type' => ProductType::POOL,
|
'type' => ProductType::POOL,
|
||||||
'manage_stock' => false,
|
'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',
|
'name' => 'Parking Spot 1',
|
||||||
'type' => ProductType::BOOKING,
|
'type' => ProductType::BOOKING,
|
||||||
'manage_stock' => true,
|
'manage_stock' => true,
|
||||||
]);
|
]);
|
||||||
$this->singleItem1->increaseStock(1);
|
|
||||||
|
|
||||||
$this->singleItem2 = Product::factory()->create([
|
$this->singleItem2 = Product::factory()
|
||||||
|
->withStocks(1)
|
||||||
|
->create([
|
||||||
'name' => 'Parking Spot 2',
|
'name' => 'Parking Spot 2',
|
||||||
'type' => ProductType::BOOKING,
|
'type' => ProductType::BOOKING,
|
||||||
'manage_stock' => true,
|
'manage_stock' => true,
|
||||||
]);
|
]);
|
||||||
$this->singleItem2->increaseStock(1);
|
|
||||||
|
|
||||||
$this->poolProduct->productRelations()->attach($this->singleItem1->id, [
|
$this->poolProduct->productRelations()->attach($this->singleItem1->id, [
|
||||||
'type' => ProductRelationType::SINGLE->value,
|
'type' => ProductRelationType::SINGLE->value,
|
||||||
|
|
@ -89,40 +81,30 @@ class CartServiceBookingTest extends TestCase
|
||||||
public function validate_bookings_returns_error_for_booking_product_without_timespan()
|
public function validate_bookings_returns_error_for_booking_product_without_timespan()
|
||||||
{
|
{
|
||||||
$cart = $this->user->currentCart();
|
$cart = $this->user->currentCart();
|
||||||
|
$cart->addToCart($this->bookingProduct, 1);
|
||||||
// Add booking product without timespan
|
|
||||||
$cart->items()->create([
|
|
||||||
'purchasable_id' => $this->bookingProduct->id,
|
|
||||||
'purchasable_type' => Product::class,
|
|
||||||
'quantity' => 1,
|
|
||||||
'price' => 100.00,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$errors = Cart::validateBookings();
|
$errors = Cart::validateBookings();
|
||||||
|
|
||||||
$this->assertNotEmpty($errors);
|
$this->assertNotEmpty($errors);
|
||||||
$this->assertStringContainsString('requires a timespan', $errors[0]);
|
$this->assertStringContainsString('requires a timespan', $errors[0]);
|
||||||
$this->assertStringContainsString('Hotel Room', $errors[0]);
|
$this->assertStringContainsString('Hotel Room', $errors[0]);
|
||||||
|
|
||||||
|
$this->assertEquals(10000, $cart->getTotal());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function validate_bookings_returns_error_for_pool_product_without_timespan_when_single_items_are_bookings()
|
public function validate_bookings_returns_error_for_pool_product_without_timespan_when_single_items_are_bookings()
|
||||||
{
|
{
|
||||||
$cart = $this->user->currentCart();
|
$cart = $this->user->currentCart();
|
||||||
|
$cart->addToCart($this->poolProduct, 1);
|
||||||
// Add pool product without timespan
|
|
||||||
$cart->items()->create([
|
|
||||||
'purchasable_id' => $this->poolProduct->id,
|
|
||||||
'purchasable_type' => Product::class,
|
|
||||||
'quantity' => 1,
|
|
||||||
'price' => 20.00,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$errors = Cart::validateBookings();
|
$errors = Cart::validateBookings();
|
||||||
|
|
||||||
$this->assertNotEmpty($errors);
|
$this->assertNotEmpty($errors);
|
||||||
$this->assertStringContainsString('requires either a timespan', $errors[0]);
|
$this->assertStringContainsString('requires either a timespan', $errors[0]);
|
||||||
$this->assertStringContainsString('Parking Spaces', $errors[0]);
|
$this->assertStringContainsString('Parking Spaces', $errors[0]);
|
||||||
|
|
||||||
|
$this->assertEquals(2000, $cart->getTotal());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|
@ -135,20 +117,15 @@ class CartServiceBookingTest extends TestCase
|
||||||
// Book all stock first
|
// Book all stock first
|
||||||
$this->bookingProduct->claimStock(10, null, $from, $until);
|
$this->bookingProduct->claimStock(10, null, $from, $until);
|
||||||
|
|
||||||
// Try to add more than available
|
$this->expectException(NotEnoughStockException::class);
|
||||||
$cart->items()->create([
|
$cart->addToCart($this->bookingProduct, 5, [], $from, $until);
|
||||||
'purchasable_id' => $this->bookingProduct->id,
|
|
||||||
'purchasable_type' => Product::class,
|
|
||||||
'quantity' => 5,
|
|
||||||
'price' => 100.00,
|
|
||||||
'from' => $from,
|
|
||||||
'until' => $until,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$errors = Cart::validateBookings();
|
$errors = Cart::validateBookings();
|
||||||
|
|
||||||
$this->assertNotEmpty($errors);
|
$this->assertNotEmpty($errors);
|
||||||
$this->assertStringContainsString('not available for the selected period', $errors[0]);
|
$this->assertStringContainsString('not available for the selected period', $errors[0]);
|
||||||
|
|
||||||
|
$this->assertEquals(0, $cart->getTotal());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue