BFIM pool cart checkout, stocks/productpurchase
This commit is contained in:
parent
d13ac99725
commit
317b28af8a
|
|
@ -242,8 +242,8 @@ return new class extends Migration
|
|||
$table->nullableUuidMorphs('purchasable');
|
||||
$table->nullableUuidMorphs('purchaser');
|
||||
$table->integer('quantity')->default(1);
|
||||
$table->decimal('amount', 10, 8)->nullable();
|
||||
$table->decimal('amount_paid', 10, 8)->default(0);
|
||||
$table->unsignedBigInteger('amount')->nullable(); // Stored in cents
|
||||
$table->unsignedBigInteger('amount_paid')->default(0); // Stored in cents
|
||||
$table->string('charge_id')->nullable();
|
||||
$table->timestamp('from')->nullable();
|
||||
$table->timestamp('until')->nullable();
|
||||
|
|
@ -308,8 +308,8 @@ return new class extends Migration
|
|||
$table->uuid('cart_id');
|
||||
$table->string('code')->nullable();
|
||||
$table->string('type')->default('percentage'); // percentage, fixed, shipping
|
||||
$table->decimal('amount', 10, 2);
|
||||
$table->decimal('discount_amount', 10, 2);
|
||||
$table->unsignedBigInteger('amount'); // Stored in cents
|
||||
$table->unsignedBigInteger('discount_amount'); // Stored in cents
|
||||
$table->json('meta')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
|
|
|
|||
|
|
@ -840,10 +840,25 @@ class Cart extends Model
|
|||
/**
|
||||
* Validate cart for checkout without converting it
|
||||
*
|
||||
* Checks:
|
||||
* 1. Cart is not already converted
|
||||
* 2. Cart is not empty
|
||||
* 3. All items have required information
|
||||
* 4. Stock is available for all items (for booking/pool products with dates)
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function validateForCheckout(bool $throws = true): bool
|
||||
{
|
||||
// Check if cart is already converted
|
||||
if ($this->isConverted()) {
|
||||
if ($throws) {
|
||||
throw new \Exception("Cart has already been converted/checked out");
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$items = $this->items()
|
||||
->with('purchasable')
|
||||
->get();
|
||||
|
|
@ -872,6 +887,90 @@ class Cart extends Model
|
|||
}
|
||||
}
|
||||
|
||||
// Validate stock availability for all items
|
||||
foreach ($items as $item) {
|
||||
$product = $item->purchasable;
|
||||
|
||||
if (!($product instanceof Product)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$from = $item->from;
|
||||
$until = $item->until;
|
||||
|
||||
// For pool products, check pool availability
|
||||
if ($product->isPool()) {
|
||||
if ($from && $until) {
|
||||
// Get available quantity considering existing cart items and pending purchases
|
||||
$available = $product->getPoolMaxQuantity($from, $until);
|
||||
|
||||
// Calculate how much of this cart's items are already counted
|
||||
// We need to check if there's still enough stock for what's in this cart
|
||||
$cartItemsForPool = $items->filter(
|
||||
fn($i) =>
|
||||
$i->purchasable_id === $product->id &&
|
||||
$i->purchasable_type === get_class($product)
|
||||
);
|
||||
$totalInCart = $cartItemsForPool->sum('quantity');
|
||||
|
||||
if ($available !== PHP_INT_MAX && $totalInCart > $available) {
|
||||
if ($throws) {
|
||||
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
|
||||
"Pool product '{$product->name}' has only {$available} items available for the period " .
|
||||
"{$from->format('Y-m-d')} to {$until->format('Y-m-d')}. Cart has: {$totalInCart}"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Without dates, check general pool availability
|
||||
$available = $product->getPoolMaxQuantity();
|
||||
$totalInCart = $items->filter(
|
||||
fn($i) =>
|
||||
$i->purchasable_id === $product->id &&
|
||||
$i->purchasable_type === get_class($product)
|
||||
)->sum('quantity');
|
||||
|
||||
if ($available !== PHP_INT_MAX && $totalInCart > $available) {
|
||||
if ($throws) {
|
||||
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
|
||||
"Pool product '{$product->name}' has only {$available} items available. Cart has: {$totalInCart}"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($product->isBooking() && $product->manage_stock) {
|
||||
// For booking products with managed stock
|
||||
if ($from && $until) {
|
||||
if (!$product->isAvailableForBooking($from, $until, $item->quantity)) {
|
||||
if ($throws) {
|
||||
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
|
||||
"Booking product '{$product->name}' is not available for the period " .
|
||||
"{$from->format('Y-m-d')} to {$until->format('Y-m-d')}. Requested: {$item->quantity}"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($product->manage_stock) {
|
||||
// For regular products with managed stock
|
||||
$available = $product->getAvailableStock();
|
||||
if ($item->quantity > $available) {
|
||||
if ($throws) {
|
||||
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
|
||||
"Product '{$product->name}' has only {$available} items in stock. Requested: {$item->quantity}"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class CartCheckoutSessionTest extends TestCase
|
|||
{
|
||||
config(['shop.stripe.enabled' => false]);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
$product = Product::factory()->create(['manage_stock' => false]);
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => Product::class,
|
||||
|
|
@ -61,6 +61,7 @@ class CartCheckoutSessionTest extends TestCase
|
|||
$product = Product::factory()->create([
|
||||
'name' => 'Test Product',
|
||||
'short_description' => 'Short desc',
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
ProductPrice::factory()->create([
|
||||
|
|
@ -95,6 +96,7 @@ class CartCheckoutSessionTest extends TestCase
|
|||
$product = Product::factory()->create([
|
||||
'name' => 'Very Long Product Name That Would Be Too Long',
|
||||
'short_description' => 'Short Name',
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
ProductPrice::factory()->create([
|
||||
|
|
@ -178,7 +180,7 @@ class CartCheckoutSessionTest extends TestCase
|
|||
config(['shop.stripe.enabled' => true]);
|
||||
config(['services.stripe.secret' => 'sk_test_fake']);
|
||||
|
||||
$product = Product::factory()->create(['name' => 'Test Product']);
|
||||
$product = Product::factory()->create(['name' => 'Test Product', 'manage_stock' => false]);
|
||||
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
|
|
@ -262,8 +264,8 @@ class CartCheckoutSessionTest extends TestCase
|
|||
config(['shop.stripe.enabled' => true]);
|
||||
config(['services.stripe.secret' => 'sk_test_fake']);
|
||||
|
||||
$product1 = Product::factory()->create(['name' => 'Product 1']);
|
||||
$product2 = Product::factory()->create(['name' => 'Product 2']);
|
||||
$product1 = Product::factory()->create(['name' => 'Product 1', 'manage_stock' => false]);
|
||||
$product2 = Product::factory()->create(['name' => 'Product 2', 'manage_stock' => false]);
|
||||
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product1->id,
|
||||
|
|
@ -310,7 +312,7 @@ class CartCheckoutSessionTest extends TestCase
|
|||
config(['shop.currency' => 'eur']);
|
||||
config(['services.stripe.secret' => 'sk_test_fake']);
|
||||
|
||||
$product = Product::factory()->create(['name' => 'Product']);
|
||||
$product = Product::factory()->create(['name' => 'Product', 'manage_stock' => false]);
|
||||
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
|
|
@ -345,7 +347,7 @@ class CartCheckoutSessionTest extends TestCase
|
|||
config(['shop.stripe.enabled' => true]);
|
||||
config(['services.stripe.secret' => 'sk_test_fake']);
|
||||
|
||||
$product = Product::factory()->create(['name' => 'Product']);
|
||||
$product = Product::factory()->create(['name' => 'Product', 'manage_stock' => false]);
|
||||
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $product->id,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Tests\Feature;
|
||||
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
use Blax\Shop\Enums\PurchaseStatus;
|
||||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||
use Blax\Shop\Models\Cart;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Models\ProductPrice;
|
||||
use Blax\Shop\Models\ProductPurchase;
|
||||
use Blax\Shop\Tests\TestCase;
|
||||
use Carbon\Carbon;
|
||||
use Workbench\App\Models\User;
|
||||
|
||||
/**
|
||||
* Test stock validation during checkout process.
|
||||
*
|
||||
* This test ensures:
|
||||
* 1. Stock is validated before checkout session creation
|
||||
* 2. Converted carts cannot create new checkout sessions
|
||||
* 3. Stock claimed by pending purchases blocks new checkouts
|
||||
*/
|
||||
class CheckoutStockValidationTest extends TestCase
|
||||
{
|
||||
protected User $user;
|
||||
protected Cart $cart;
|
||||
protected Product $pool;
|
||||
protected array $singles;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
auth()->login($this->user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pool with managed stock single items
|
||||
*/
|
||||
protected function createPoolWithManagedStock(): void
|
||||
{
|
||||
// Create pool
|
||||
$this->pool = Product::factory()->create([
|
||||
'name' => 'Test Pool',
|
||||
'type' => ProductType::POOL,
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
// Pool default price
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $this->pool->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'unit_amount' => 5000,
|
||||
'currency' => 'USD',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$this->pool->setPoolPricingStrategy('lowest');
|
||||
|
||||
// Create 2 single items with 1 stock each
|
||||
$this->singles = [];
|
||||
|
||||
$single1 = Product::factory()->create([
|
||||
'name' => 'Single 1',
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
$single1->increaseStock(1);
|
||||
$this->singles[] = $single1;
|
||||
|
||||
$single2 = Product::factory()->create([
|
||||
'name' => 'Single 2',
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
$single2->increaseStock(1);
|
||||
$this->singles[] = $single2;
|
||||
|
||||
$this->pool->attachSingleItems(array_map(fn($s) => $s->id, $this->singles));
|
||||
}
|
||||
|
||||
protected function createCart(): Cart
|
||||
{
|
||||
return Cart::factory()->create([
|
||||
'customer_id' => $this->user->id,
|
||||
'customer_type' => get_class($this->user),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function validate_for_checkout_checks_stock_availability()
|
||||
{
|
||||
$this->createPoolWithManagedStock();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
$from = Carbon::tomorrow()->startOfDay();
|
||||
$until = Carbon::tomorrow()->addDay()->startOfDay();
|
||||
|
||||
// Add 2 items (all available stock)
|
||||
$this->cart->addToCart($this->pool, 2, [], $from, $until);
|
||||
|
||||
// Should pass validation
|
||||
$this->assertTrue($this->cart->validateForCheckout(false));
|
||||
|
||||
// Now claim stock for the single items (simulating another completed purchase)
|
||||
foreach ($this->singles as $single) {
|
||||
$single->claimStock(1, null, $from, $until, 'Test claim');
|
||||
}
|
||||
|
||||
// Now validation should fail - stock is claimed by another purchase
|
||||
$this->assertFalse($this->cart->fresh()->validateForCheckout(false));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function validate_for_checkout_fails_for_out_of_stock_items()
|
||||
{
|
||||
$this->createPoolWithManagedStock();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
$from = Carbon::tomorrow()->startOfDay();
|
||||
$until = Carbon::tomorrow()->addDay()->startOfDay();
|
||||
|
||||
// Add 2 items
|
||||
$this->cart->addToCart($this->pool, 2, [], $from, $until);
|
||||
|
||||
// Manually deplete stock (simulating another purchase)
|
||||
foreach ($this->singles as $single) {
|
||||
$single->decreaseStock(1);
|
||||
}
|
||||
|
||||
// Validation should fail because stock is no longer available
|
||||
$this->assertFalse($this->cart->validateForCheckout(false));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function validate_for_checkout_fails_for_converted_cart()
|
||||
{
|
||||
$this->createPoolWithManagedStock();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
$from = Carbon::tomorrow()->startOfDay();
|
||||
$until = Carbon::tomorrow()->addDay()->startOfDay();
|
||||
|
||||
$this->cart->addToCart($this->pool, 1, [], $from, $until);
|
||||
|
||||
// Mark cart as converted
|
||||
$this->cart->update(['converted_at' => now()]);
|
||||
|
||||
// Validation should fail for converted cart
|
||||
$this->assertFalse($this->cart->fresh()->validateForCheckout(false));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function checkout_session_link_returns_null_for_converted_cart()
|
||||
{
|
||||
$this->createPoolWithManagedStock();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
$from = Carbon::tomorrow()->startOfDay();
|
||||
$until = Carbon::tomorrow()->addDay()->startOfDay();
|
||||
|
||||
$this->cart->addToCart($this->pool, 1, [], $from, $until);
|
||||
|
||||
// Mark cart as converted
|
||||
$this->cart->update(['converted_at' => now()]);
|
||||
|
||||
// validateForCheckout should fail for converted cart
|
||||
$this->assertFalse($this->cart->fresh()->validateForCheckout(false));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function checkout_session_link_returns_null_for_out_of_stock()
|
||||
{
|
||||
$this->createPoolWithManagedStock();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
$from = Carbon::tomorrow()->startOfDay();
|
||||
$until = Carbon::tomorrow()->addDay()->startOfDay();
|
||||
|
||||
$this->cart->addToCart($this->pool, 2, [], $from, $until);
|
||||
|
||||
// Deplete stock
|
||||
foreach ($this->singles as $single) {
|
||||
$single->decreaseStock(1);
|
||||
}
|
||||
|
||||
// validateForCheckout should fail (stock not available)
|
||||
$this->assertFalse($this->cart->fresh()->validateForCheckout(false));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function different_date_ranges_allow_booking_same_items()
|
||||
{
|
||||
$this->createPoolWithManagedStock();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
// Book for tomorrow
|
||||
$from1 = Carbon::tomorrow()->startOfDay();
|
||||
$until1 = Carbon::tomorrow()->addDay()->startOfDay();
|
||||
|
||||
$this->cart->addToCart($this->pool, 2, [], $from1, $until1);
|
||||
$this->assertTrue($this->cart->validateForCheckout(false));
|
||||
|
||||
// Create another cart for different dates
|
||||
$cart2 = $this->createCart();
|
||||
|
||||
// Book for day after tomorrow (non-overlapping)
|
||||
$from2 = Carbon::tomorrow()->addDays(2)->startOfDay();
|
||||
$until2 = Carbon::tomorrow()->addDays(3)->startOfDay();
|
||||
|
||||
// This should succeed because dates don't overlap
|
||||
$cart2->addToCart($this->pool, 2, [], $from2, $until2);
|
||||
$this->assertTrue($cart2->validateForCheckout(false));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function overlapping_dates_block_double_booking()
|
||||
{
|
||||
$this->createPoolWithManagedStock();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
$from = Carbon::tomorrow()->startOfDay();
|
||||
$until = Carbon::tomorrow()->addDays(3)->startOfDay(); // 3 days
|
||||
|
||||
// Book all stock for 3 days
|
||||
$this->cart->addToCart($this->pool, 2, [], $from, $until);
|
||||
$this->assertTrue($this->cart->validateForCheckout(false));
|
||||
|
||||
// Claim stock (simulating completed purchase)
|
||||
foreach ($this->singles as $single) {
|
||||
$single->claimStock(1, null, $from, $until, 'Test claim');
|
||||
}
|
||||
|
||||
// Create another cart
|
||||
$cart2 = $this->createCart();
|
||||
|
||||
// Try to book overlapping date range (day 2)
|
||||
$from2 = Carbon::tomorrow()->addDay()->startOfDay();
|
||||
$until2 = Carbon::tomorrow()->addDays(2)->startOfDay();
|
||||
|
||||
// This should fail because dates overlap and all stock is claimed
|
||||
$this->expectException(NotEnoughStockException::class);
|
||||
$cart2->addToCart($this->pool, 1, [], $from2, $until2);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function partial_stock_allows_partial_booking()
|
||||
{
|
||||
$this->createPoolWithManagedStock();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
$from = Carbon::tomorrow()->startOfDay();
|
||||
$until = Carbon::tomorrow()->addDay()->startOfDay();
|
||||
|
||||
// Book 1 of 2 available
|
||||
$this->cart->addToCart($this->pool, 1, [], $from, $until);
|
||||
$this->assertTrue($this->cart->validateForCheckout(false));
|
||||
|
||||
// Claim stock for just 1 single item
|
||||
$this->singles[0]->claimStock(1, null, $from, $until, 'Test claim');
|
||||
|
||||
// Create another cart - should be able to book 1 more for same dates
|
||||
$cart2 = $this->createCart();
|
||||
$cart2->addToCart($this->pool, 1, [], $from, $until);
|
||||
$this->assertTrue($cart2->validateForCheckout(false));
|
||||
|
||||
// But not 2 - only 1 remaining
|
||||
$cart3 = $this->createCart();
|
||||
$this->expectException(NotEnoughStockException::class);
|
||||
$cart3->addToCart($this->pool, 2, [], $from, $until);
|
||||
}
|
||||
}
|
||||
|
|
@ -316,8 +316,9 @@ class PoolProductCheckoutTest extends TestCase
|
|||
$this->parkingSpot1->claimStock(1, null, $from, $until);
|
||||
$this->parkingSpot2->claimStock(1, null, $from, $until);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('Failed to checkout pool product');
|
||||
// validateForCheckout will now catch this before checkout even starts
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||
$this->expectExceptionMessage('has only 1 items available');
|
||||
|
||||
$cart->checkout();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue