BFIM pool cart checkout, stocks/productpurchase

This commit is contained in:
Fabian @ Blax Software 2025-12-19 13:32:00 +01:00
parent d13ac99725
commit 317b28af8a
5 changed files with 388 additions and 12 deletions

View File

@ -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();

View File

@ -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;
}

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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();
}