BFI tests fixing, prices, purchases, cart

This commit is contained in:
a6a2f5842 2025-11-23 15:07:12 +01:00
parent fda536deea
commit ab1e2468ca
17 changed files with 440 additions and 321 deletions

View File

@ -1,5 +1,5 @@
{ {
"editor.formatOnSave": true, "editor.formatOnSave": false,
"[php]": { "[php]": {
"editor.defaultFormatter": "bmewburn.vscode-intelephense-client" "editor.defaultFormatter": "bmewburn.vscode-intelephense-client"
}, },

View File

@ -68,4 +68,25 @@ class ProductFactory extends Factory
{ {
return $this->state(['featured' => true]); return $this->state(['featured' => true]);
} }
public function withPrices(int $count = 1): static
{
return $this->afterCreating(function (Product $product) use ($count) {
$prices = \Blax\Shop\Models\ProductPrice::factory()
->count($count)
->create([
'purchasable_type' => get_class($product),
'purchasable_id' => $product->id,
'unit_amount' => $this->faker->randomFloat(2, 10, 1000),
'currency' => 'EUR',
]);
// Set the first price as default
if ($prices->isNotEmpty()) {
$defaultPrice = $prices->first();
$defaultPrice->is_default = true;
$defaultPrice->save();
}
});
}
} }

View File

@ -0,0 +1,21 @@
<?php
namespace Blax\Shop\Database\Factories;
use Blax\Shop\Models\ProductPrice;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class ProductPriceFactory extends Factory
{
protected $model = ProductPrice::class;
public function definition()
{
return [
'unit_amount' => $this->faker->randomFloat(2, 1, 1000),
'currency' => 'EUR',
'is_default' => false,
];
}
}

View File

@ -116,13 +116,13 @@ return new class extends Migration
if (!Schema::hasTable(config('shop.tables.product_prices', 'product_prices'))) { if (!Schema::hasTable(config('shop.tables.product_prices', 'product_prices'))) {
Schema::create(config('shop.tables.product_prices', 'product_prices'), function (Blueprint $table) { Schema::create(config('shop.tables.product_prices', 'product_prices'), function (Blueprint $table) {
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->uuid('product_id'); $table->uuidMorphs('purchasable');
$table->string('stripe_price_id')->nullable(); $table->string('stripe_price_id')->nullable();
$table->string('name')->nullable(); $table->string('name')->nullable();
$table->string('type')->default('one_time'); // one_time, recurring $table->string('type')->default('one_time'); // one_time, recurring
$table->string('currency', 3)->default('USD'); $table->string('currency', 3)->default('EUR');
$table->integer('price')->default(0); // Store as smallest currency unit (cents) $table->integer('unit_amount')->default(0); // Store as smallest currency unit (cents)
$table->integer('sale_price')->nullable(); $table->integer('sale_unit_amount')->nullable();
$table->boolean('is_default')->default(false); $table->boolean('is_default')->default(false);
$table->boolean('active')->default(true); $table->boolean('active')->default(true);
$table->string('billing_scheme')->nullable(); // per_unit, tiered $table->string('billing_scheme')->nullable(); // per_unit, tiered
@ -132,10 +132,7 @@ return new class extends Migration
$table->json('meta')->nullable(); $table->json('meta')->nullable();
$table->timestamps(); $table->timestamps();
$table->index(['product_id', 'currency']); $table->index('currency');
$table->index(['product_id', 'is_default']);
$table->index(['active', 'type']);
$table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
}); });
} }
@ -271,18 +268,17 @@ return new class extends Migration
Schema::create(config('shop.tables.cart_items', 'cart_items'), function (Blueprint $table) { Schema::create(config('shop.tables.cart_items', 'cart_items'), function (Blueprint $table) {
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->uuid('cart_id'); $table->uuid('cart_id');
$table->uuid('product_id'); $table->uuidMorphs('purchasable');
$table->integer('quantity')->default(1); $table->integer('quantity')->default(1);
$table->decimal('price', 10, 2); $table->decimal('price', 10, 2)->default(0);
$table->decimal('regular_price', 10, 2)->nullable(); $table->decimal('regular_price', 10, 2)->nullable();
$table->decimal('subtotal', 10, 2); $table->decimal('subtotal', 10, 2);
$table->json('attributes')->nullable(); $table->json('parameters')->nullable();
$table->json('meta')->nullable(); $table->json('meta')->nullable();
$table->timestamps(); $table->timestamps();
$table->index(['cart_id', 'product_id']); $table->index(['cart_id', 'product_id']);
$table->foreign('cart_id')->references('id')->on(config('shop.tables.carts', 'carts'))->onDelete('cascade'); $table->foreign('cart_id')->references('id')->on(config('shop.tables.carts', 'carts'))->onDelete('cascade');
$table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
}); });
} }

View File

@ -0,0 +1,7 @@
<?php
namespace Blax\Shop\Contracts;
interface Cartable
{
}

View File

@ -10,5 +10,7 @@ interface Purchasable
public function decreaseStock(int $quantity = 1): bool; public function decreaseStock(int $quantity = 1): bool;
public function increaseStock(int $quantity = 1): void; public function increaseStock(int $quantity = 1): bool;
public function purchases();
} }

View File

@ -0,0 +1,7 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class NotEnoughStockException extends Exception {}

View File

@ -2,6 +2,7 @@
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable;
use Blax\Workkit\Traits\HasExpiration; use Blax\Workkit\Traits\HasExpiration;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -109,4 +110,30 @@ class Cart extends Model
$cart->items()->delete(); $cart->items()->delete();
}); });
} }
public function addToCart(
Model $cartable,
$quantity = 1,
$parameters = []
) : CartItem {
// $cartable must implement Cartable
if (! $cartable instanceof Cartable) {
throw new \Exception("Item must implement the Cartable interface.");
}
$cartItem = $this->items()->create([
'purchasable_id' => $cartable->getKey(),
'purchasable_type' => get_class($cartable),
'quantity' => $quantity,
'price' => ($cartable?->sale_unit_amount ?? $cartable?->unit_amount ?? 0),
'regular_price' => $cartable?->unit_amount,
'subtotal' => ($cartable?->sale_unit_amount ?? $cartable?->unit_amount ?? 0) * $quantity,
'parameters' => $parameters,
]);
$cartItem = $cartItem->fresh();
return $cartItem;
}
} }

View File

@ -2,22 +2,24 @@
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Workkit\Traits\HasMeta;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CartItem extends Model class CartItem extends Model
{ {
use HasUuids; use HasUuids, HasMeta;
protected $fillable = [ protected $fillable = [
'cart_id', 'cart_id',
'product_id', 'purchasable_id',
'purchasable_type',
'quantity', 'quantity',
'price', 'price',
'regular_price', 'regular_price',
'subtotal', 'subtotal',
'attributes', 'parameters',
'meta', 'meta',
]; ];
@ -26,7 +28,7 @@ class CartItem extends Model
'price' => 'decimal:2', 'price' => 'decimal:2',
'regular_price' => 'decimal:2', 'regular_price' => 'decimal:2',
'subtotal' => 'decimal:2', 'subtotal' => 'decimal:2',
'attributes' => 'array', 'parameters' => 'array',
'meta' => 'array', 'meta' => 'array',
]; ];
@ -59,9 +61,18 @@ class CartItem extends Model
return $this->belongsTo(config('shop.models.cart'), 'cart_id'); return $this->belongsTo(config('shop.models.cart'), 'cart_id');
} }
public function product(): BelongsTo public function purchasable()
{ {
return $this->belongsTo(config('shop.models.product'), 'product_id'); return $this->morphTo('purchasable');
}
public function product(): BelongsTo|null
{
if ($this->purchasable_type === config('shop.models.product', Product::class)) {
return $this->belongsTo(config('shop.models.product'), 'purchasable_id');
}
return null;
} }
public function getSubtotal(): float public function getSubtotal(): float

View File

@ -3,19 +3,22 @@
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use App\Services\StripeService; use App\Services\StripeService;
use Blax\Shop\Contracts\Cartable;
use Blax\Workkit\Traits\HasMetaTranslation; use Blax\Workkit\Traits\HasMetaTranslation;
use Blax\Shop\Events\ProductCreated; use Blax\Shop\Events\ProductCreated;
use Blax\Shop\Events\ProductUpdated; use Blax\Shop\Events\ProductUpdated;
use Blax\Shop\Contracts\Purchasable; use Blax\Shop\Contracts\Purchasable;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class Product extends Model implements Purchasable class Product extends Model implements Purchasable, Cartable
{ {
use HasFactory, HasUuids, HasMetaTranslation; use HasFactory, HasUuids, HasMetaTranslation;
@ -49,7 +52,6 @@ class Product extends Model implements Purchasable
protected $casts = [ protected $casts = [
'manage_stock' => 'boolean', 'manage_stock' => 'boolean',
'in_stock' => 'boolean',
'virtual' => 'boolean', 'virtual' => 'boolean',
'downloadable' => 'boolean', 'downloadable' => 'boolean',
'meta' => 'object', 'meta' => 'object',
@ -62,8 +64,6 @@ class Product extends Model implements Purchasable
'sort_order' => 'integer', 'sort_order' => 'integer',
]; ];
// Remove - causes issues with casting
protected $dispatchesEvents = [ protected $dispatchesEvents = [
'created' => ProductCreated::class, 'created' => ProductCreated::class,
'updated' => ProductUpdated::class, 'updated' => ProductUpdated::class,
@ -125,9 +125,12 @@ class Product extends Model implements Purchasable
}); });
} }
public function prices(): HasMany public function prices(): MorphMany
{ {
return $this->hasMany(config('shop.models.product_price', ProductPrice::class)); return $this->morphMany(
config('shop.models.product_price', ProductPrice::class),
'purchasable'
);
} }
public function parent() public function parent()
@ -163,9 +166,17 @@ class Product extends Model implements Purchasable
return $this->hasMany(config('shop.models.product_action', ProductAction::class)); return $this->hasMany(config('shop.models.product_action', ProductAction::class));
} }
public function activeStocks(): HasMany public function getAvailableStocksAttribute(): int
{ {
return $this->stocks()->pending(); return $this->stocks()->available()->sum('quantity') ?? 0;
}
public function purchases(): MorphMany
{
return $this->morphMany(
config('shop.models.product_purchase', ProductPurchase::class),
'purchasable'
);
} }
public function scopePublished($query) public function scopePublished($query)
@ -180,10 +191,6 @@ class Product extends Model implements Purchasable
public function isOnSale(): bool public function isOnSale(): bool
{ {
if (!$this->sale_price) {
return false;
}
$now = now(); $now = now();
if ($this->sale_start && $now->lt($this->sale_start)) { if ($this->sale_start && $now->lt($this->sale_start)) {
@ -207,30 +214,55 @@ class Product extends Model implements Purchasable
return $defaultPrice ? $defaultPrice->price : $this->regular_price; return $defaultPrice ? $defaultPrice->price : $this->regular_price;
} }
public function isInStock(): bool
{
if (!$this->manage_stock) {
return true;
}
return $this->getAvailableStock() > 0;
}
public function decreaseStock(int $quantity = 1): bool public function decreaseStock(int $quantity = 1): bool
{ {
if (!$this->manage_stock) { if (!$this->manage_stock) {
return true; return true;
} }
if (config('shop.stock.log_changes', true)) { if ($this->AvailableStocks < $quantity) {
$this->logStockChange(-$quantity, 'decrease'); return throw new NotEnoughStockException("Not enough stock available for product ID {$this->id}");
} }
$this->stocks()->create([
'quantity' => -$quantity,
'type' => 'decrease',
'status' => 'completed',
]);
$this->logStockChange(-$quantity, 'decrease');
$this->save(); $this->save();
return true; return true;
} }
public function increaseStock(int $quantity = 1): void public function increaseStock(int $quantity = 1): bool
{ {
if (!$this->manage_stock) { if (!$this->manage_stock) {
return; return false;
} }
$this->stocks()->create([
'quantity' => $quantity,
'type' => 'increase',
'status' => 'completed',
]);
$this->logStockChange($quantity, 'increase'); $this->logStockChange($quantity, 'increase');
$this->save(); $this->save();
return true;
} }
public function reserveStock( public function reserveStock(
@ -257,7 +289,7 @@ class Product extends Model implements Purchasable
return PHP_INT_MAX; return PHP_INT_MAX;
} }
return max(0, $this->stock_quantity); return max(0, $this->AvailableStocks);
} }
public function getReservedStock(): int public function getReservedStock(): int
@ -315,6 +347,17 @@ class Product extends Model implements Purchasable
return $this->relatedProducts()->wherePivot('type', 'cross-sell'); return $this->relatedProducts()->wherePivot('type', 'cross-sell');
} }
public function scopeInStock($query)
{
return $query->where(function ($q) {
$q->where('manage_stock', false)
->orWhere(function ($q2) {
$q2->where('manage_stock', true)
->whereRaw("(SELECT SUM(quantity) FROM " . config('shop.tables.product_stocks', 'product_stocks') . " WHERE product_id = " . config('shop.tables.products', 'products') . ".id) > 0");
});
});
}
public function scopeVisible($query) public function scopeVisible($query)
{ {
return $query->where('is_visible', true) return $query->where('is_visible', true)
@ -399,9 +442,6 @@ class Product extends Model implements Purchasable
'regular_price' => $this->regular_price, 'regular_price' => $this->regular_price,
'sale_price' => $this->sale_price, 'sale_price' => $this->sale_price,
'is_on_sale' => $this->isOnSale(), 'is_on_sale' => $this->isOnSale(),
'in_stock' => $this->in_stock,
'stock_quantity' => $this->manage_stock ? $this->stock_quantity : null,
'stock_status' => $this->stock_status,
'low_stock' => $this->isLowStock(), 'low_stock' => $this->isLowStock(),
'featured' => $this->featured, 'featured' => $this->featured,
'virtual' => $this->virtual, 'virtual' => $this->virtual,
@ -456,4 +496,9 @@ class Product extends Model implements Purchasable
return parent::newInstance($attributes, $exists); return parent::newInstance($attributes, $exists);
} }
public function defaultPrice()
{
return $this->prices()->where('is_default', true);
}
} }

View File

@ -2,43 +2,48 @@
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable;
use Blax\Shop\Contracts\Purchasable;
use Blax\Workkit\Traits\HasMetaTranslation; use Blax\Workkit\Traits\HasMetaTranslation;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class ProductPrice extends Model class ProductPrice extends Model implements Cartable
{ {
use HasUuids, HasMetaTranslation; use HasFactory, HasUuids, HasMetaTranslation;
protected $fillable = [ protected $fillable = [
'active', 'purchasable_type',
'product_id', 'purchasable_id',
'stripe_price_id', 'stripe_price_id',
'name', 'name',
'type', 'type',
'price', 'currency',
'sale_price', 'unit_amount',
'sale_unit_amount',
'is_default', 'is_default',
'active',
'billing_scheme', 'billing_scheme',
'interval', 'interval',
'interval_count', 'interval_count',
'trial_period_days', 'trial_period_days',
'currency',
'meta', 'meta',
]; ];
protected $casts = [ protected $casts = [
'price' => 'integer',
'sale_price' => 'integer',
'is_default' => 'boolean', 'is_default' => 'boolean',
'trial_period_days' => 'integer',
'meta' => 'object',
'active' => 'boolean', 'active' => 'boolean',
'meta' => 'object',
'unit_amount' => 'float',
'sale_unit_amount' => 'float',
'interval_count' => 'integer',
'trial_period_days' => 'integer',
]; ];
public function product() public function purchasable()
{ {
return $this->belongsTo(Product::class); return $this->morphTo();
} }
public function scopeIsActive($query) public function scopeIsActive($query)

View File

@ -13,8 +13,10 @@ class ProductPurchase extends Model
'status', 'status',
'cart_id', 'cart_id',
'price_id', 'price_id',
'purchasable', 'purchasable_id',
'purchaser', 'purchasable_type',
'purchaser_id',
'purchaser_type',
'quantity', 'quantity',
'amount', 'amount',
'amount_paid', 'amount_paid',
@ -37,12 +39,12 @@ class ProductPurchase extends Model
public function purchasable() public function purchasable()
{ {
return $this->morphTo(); return $this->morphTo('purchasable');
} }
public function purchaser() public function purchaser()
{ {
return $this->morphTo(); return $this->morphTo('purchaser');
} }
public function product() public function product()

View File

@ -3,6 +3,7 @@
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Workkit\Traits\HasExpiration;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -11,7 +12,7 @@ use Illuminate\Support\Facades\DB;
class ProductStock extends Model class ProductStock extends Model
{ {
use HasUuids; use HasUuids, HasExpiration;
protected $fillable = [ protected $fillable = [
'product_id', 'product_id',
@ -70,16 +71,6 @@ class ProductStock extends Model
return $query->where('status', 'completed'); return $query->where('status', 'completed');
} }
public function scopeExpired($query)
{
return $query->where('status', 'expired')
->orWhere(function ($q) {
$q->where('status', 'pending')
->whereNotNull('expires_at')
->where('expires_at', '<=', now());
});
}
public function scopeTemporary($query) public function scopeTemporary($query)
{ {
return $query->whereNotNull('expires_at'); return $query->whereNotNull('expires_at');
@ -216,4 +207,9 @@ class ProductStock extends Model
return $count; return $count;
} }
public static function scopeAvailable($query)
{
return $query->where('status', 'completed');
}
} }

View File

@ -2,31 +2,43 @@
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Models\CartItem;
use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice; use Blax\Shop\Models\ProductPrice;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
trait HasShoppingCapabilities trait HasShoppingCapabilities
{ {
public function cart(): MorphMany
{
return $this->morphMany(
config('shop.models.cart', \Blax\Shop\Models\Cart::class),
'customer'
);
}
/** /**
* Get all purchases made by this entity * Get all purchases made by this entity
*/ */
public function purchases(): MorphMany public function purchases(): MorphMany
{ {
// This morph represents the purchaser (e.g. User), not the product.
return $this->morphMany( return $this->morphMany(
config('shop.models.product_purchase', ProductPurchase::class), config('shop.models.product_purchase', ProductPurchase::class),
'purchasable' 'purchaser'
); );
} }
/** /**
* Get cart items (purchases with status 'cart') * Get cart items (purchases with status 'cart')
*/ */
public function cartItems(): MorphMany public function cartItems(): HasMany
{ {
return $this->purchases()->where('status', 'cart'); return $this->cart()->latest()->firstOrCreate()->items();
} }
/** /**
@ -50,16 +62,21 @@ trait HasShoppingCapabilities
ProductPrice|string $productPrice, ProductPrice|string $productPrice,
int $quantity = 1, int $quantity = 1,
): ProductPurchase { ): ProductPurchase {
if ($productPrice instanceof ProductPrice) {
} else {
$productPrice = ProductPrice::findOrFail($productPrice);
}
if (!$productPrice?->product?->id) { $productPrice = ($productPrice instanceof ProductPrice)
? $productPrice
: ProductPrice::findOrFail($productPrice);
if (!$productPrice?->purchasable?->id) {
throw new \Exception("Price does not belong to the specified product"); throw new \Exception("Price does not belong to the specified product");
} }
$product = $productPrice->product; $product = $productPrice->purchasable;
// product must have interface Purchasable
if (!in_array('Blax\Shop\Contracts\Purchasable', class_implements($product))) {
throw new \Exception("The product is not purchasable");
}
// Validate stock availability // Validate stock availability
if ($product->manage_stock) { if ($product->manage_stock) {
@ -81,7 +98,10 @@ trait HasShoppingCapabilities
// Create purchase record // Create purchase record
$purchase = $this->purchases()->create([ $purchase = $this->purchases()->create([
'product_id' => $product->id, 'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'purchaser_id' => $this->getKey(),
'purchaser_type' => get_class($this),
'quantity' => $quantity, 'quantity' => $quantity,
'status' => 'unpaid', 'status' => 'unpaid',
'meta' => array_merge([ 'meta' => array_merge([
@ -96,64 +116,48 @@ trait HasShoppingCapabilities
'purchaser' => $this, 'purchaser' => $this,
]); ]);
$purchase->fresh();
if (!$purchase) {
throw new \Exception("Unable to create purchase record");
}
if (!$purchase->purchasable || $purchase->purchasable->id !== $product->id) {
throw new \Exception("Purchase record does not match the product");
}
return $purchase; return $purchase;
} }
/** /**
* Add product to cart * Add product to cart
* *
* @param Product $product * @param Product|ProductPrice $price
* @param int $quantity * @param int $quantity
* @param array $options * @param array $options
* @return ProductPurchase * @return CartItem
* @throws \Exception * @throws \Exception
*/ */
public function addToCart(Product $product, int $quantity = 1, array $options = []): ProductPurchase public function addToCart(Product|ProductPrice $price, int $quantity = 1, array $parameters = []): CartItem
{ {
// Check if product already in cart return $this->cart()->latest()->firstOrCreate()->addToCart(
$existingItem = $this->cartItems() $price,
->where('product_id', $product->id) $quantity,
->first(); $parameters
);
if ($existingItem) {
return $this->updateCartQuantity($existingItem, $existingItem->quantity + $quantity);
}
// Validate stock
if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
throw new \Exception("Insufficient stock available");
}
$priceId = $options['price_id'] ?? null;
$price = $this->determinePurchasePrice($product, $priceId);
return $this->purchases()->create([
'product_id' => $product->id,
'quantity' => $quantity,
'status' => 'cart',
'meta' => array_merge([
'price_id' => $priceId,
'price' => $price,
'amount' => $price * $quantity,
], $options['meta'] ?? []),
]);
} }
/** /**
* Update cart item quantity * Update cart item quantity
* *
* @param ProductPurchase $cartItem * @param CartItem $cartItem
* @param int $quantity * @param int $quantity
* @return ProductPurchase * @return CartItem
* @throws \Exception * @throws \Exception
*/ */
public function updateCartQuantity(ProductPurchase $cartItem, int $quantity): ProductPurchase public function updateCartQuantity(CartItem $cartItem, int $quantity): CartItem
{ {
if ($cartItem->status !== 'cart') { $product = $cartItem->purchasable;
throw new \Exception("Cannot update non-cart item");
}
$product = $cartItem->product;
// Validate stock // Validate stock
if ($product->manage_stock && $product->getAvailableStock() < $quantity) { if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
@ -161,15 +165,9 @@ trait HasShoppingCapabilities
} }
$meta = (array) $cartItem->meta; $meta = (array) $cartItem->meta;
$priceId = $meta['price_id'] ?? null;
$price = $this->determinePurchasePrice($product, $priceId);
$cartItem->update([ $cartItem->update([
'quantity' => $quantity, 'quantity' => $quantity,
'meta' => array_merge($meta, [
'price' => $price,
'amount' => $price * $quantity,
]),
]); ]);
return $cartItem->fresh(); return $cartItem->fresh();
@ -178,17 +176,13 @@ trait HasShoppingCapabilities
/** /**
* Remove item from cart * Remove item from cart
* *
* @param ProductPurchase $cartItem * @param CartItem $cartItem
* @return bool * @return bool
* @throws \Exception * @throws \Exception
*/ */
public function removeFromCart(ProductPurchase $cartItem): bool public function removeFromCart(CartItem $cartItem): bool
{ {
if ($cartItem->status !== 'cart') { return $cartItem->forceDelete();
throw new \Exception("Cannot remove non-cart item");
}
return $cartItem->delete();
} }
/** /**
@ -237,56 +231,19 @@ trait HasShoppingCapabilities
*/ */
public function checkout(?string $cartId = null, array $options = []): Collection public function checkout(?string $cartId = null, array $options = []): Collection
{ {
$items = $this->cartItems()->with('product')->get(); $items = $this->cartItems()
->with('purchasable')
->get();
if ($items->isEmpty()) { if ($items->isEmpty()) {
throw new \Exception("Cart is empty"); throw new \Exception("Cart is empty");
} }
// Validate stock for all items $purchases = collect();
foreach ($items as $item) {
$product = $item->product;
if ($product->manage_stock && $product->getAvailableStock() < $item->quantity) {
throw new \Exception("Insufficient stock for: {$product->getLocalized('name')}");
}
}
// Process each item //
$completedPurchases = collect();
foreach ($items as $item) {
$product = $item->product;
// Decrease stock return $purchases;
if (!$product->decreaseStock($item->quantity)) {
// Rollback previous purchases
foreach ($completedPurchases as $purchase) {
$purchase->product->increaseStock($purchase->quantity);
$purchase->delete();
}
throw new \Exception("Unable to process checkout");
}
// Update status and store charge info in meta
$meta = array_merge((array) $item->meta, [
'charge_id' => $options['charge_id'] ?? null,
'completed_at' => now()->toISOString(),
]);
$item->update([
'status' => 'completed',
'meta' => $meta,
]);
// Trigger actions
$product->callActions('purchased', $item, [
'purchaser' => $this,
...$options,
]);
$completedPurchases->push($item);
}
return $completedPurchases;
} }
/** /**

View File

@ -5,6 +5,7 @@ namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem; use Blax\Shop\Models\CartItem;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase; use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User; use Workbench\App\Models\User;
@ -44,15 +45,21 @@ class CartManagementTest extends TestCase
/** @test */ /** @test */
public function it_can_add_items_to_cart() public function it_can_add_items_to_cart()
{ {
$product = Product::factory()->create();
$price = ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
]);
$cart = Cart::create(); $cart = Cart::create();
$product = Product::factory()->create(['price' => 99.99]);
$cartItem = CartItem::create([ $cartItem = CartItem::create([
'cart_id' => $cart->id, 'cart_id' => $cart->id,
'product_id' => $product->id, 'purchasable_id' => $price->id,
'purchasable_type' => get_class($price),
'quantity' => 2, 'quantity' => 2,
'price' => $product->price, 'price' => $price->unit_amount,
'subtotal' => $product->price * 2, 'subtotal' => $price->unit_amount * 2,
]); ]);
$this->assertCount(1, $cart->fresh()->items); $this->assertCount(1, $cart->fresh()->items);
@ -64,15 +71,13 @@ class CartManagementTest extends TestCase
{ {
$cart = Cart::create(); $cart = Cart::create();
$product = Product::factory()->create(['price' => 50.00]); $product = Product::factory()->create(['price' => 50.00]);
$price = ProductPrice::factory()->create([
$cartItem = CartItem::create([ 'purchasable_id' => $product->id,
'cart_id' => $cart->id, 'purchasable_type' => get_class($product),
'product_id' => $product->id, 'unit_amount' => 50.00,
'quantity' => 1,
'price' => $product->price,
'subtotal' => $product->price,
]); ]);
$cartItem = $cart->addToCart($price, quantity: 1);
$cartItem->update(['quantity' => 3]); $cartItem->update(['quantity' => 3]);
$this->assertEquals(3, $cartItem->fresh()->quantity); $this->assertEquals(3, $cartItem->fresh()->quantity);
@ -82,46 +87,44 @@ class CartManagementTest extends TestCase
public function it_can_remove_items_from_cart() public function it_can_remove_items_from_cart()
{ {
$cart = Cart::create(); $cart = Cart::create();
$product = Product::factory()->create(['price' => 75.00]); $product = Product::factory()->create();
$price = ProductPrice::factory()->create([
$cartItem = CartItem::create([ 'purchasable_id' => $product->id,
'cart_id' => $cart->id, 'purchasable_type' => get_class($product),
'product_id' => $product->id, 'unit_amount' => 100.00,
'quantity' => 1,
'price' => $product->price,
'subtotal' => $product->price,
]); ]);
$this->assertCount(1, $cart->fresh()->items); $cartItem = $cart->addToCart($price, quantity: 1);
$this->assertCount(1, $cart->items);
$cartItem->delete(); $cartItem->delete();
$this->assertCount(0, $cart->fresh()->items); $this->assertCount(0, $cart->refresh()->items);
} }
/** @test */ /** @test */
public function it_calculates_cart_total_correctly() public function it_calculates_cart_total_correctly()
{ {
$cart = Cart::create(); $cart = Cart::create();
$product1 = Product::factory()->create(['price' => 50.00]); $product1 = Product::factory()->create();
$product2 = Product::factory()->create(['price' => 30.00]); $product2 = Product::factory()->create();
CartItem::create([ $productPrice1 = ProductPrice::factory()->create([
'cart_id' => $cart->id, 'purchasable_id' => $product1->id,
'product_id' => $product1->id, 'purchasable_type' => get_class($product1),
'quantity' => 2, 'unit_amount' => 50.00,
'price' => $product1->price,
'subtotal' => $product1->price * 2,
]); ]);
CartItem::create([ $productPrice2 = ProductPrice::factory()->create([
'cart_id' => $cart->id, 'purchasable_id' => $product2->id,
'product_id' => $product2->id, 'purchasable_type' => get_class($product2),
'quantity' => 1, 'unit_amount' => 30.00,
'price' => $product2->price,
'subtotal' => $product2->price,
]); ]);
$cart->addToCart($productPrice1, quantity: 2);
$cart->addToCart($productPrice2, quantity: 1);
$total = $cart->fresh()->getTotal(); $total = $cart->fresh()->getTotal();
$this->assertEquals(130.00, $total); // (50 * 2) + (30 * 1) $this->assertEquals(130.00, $total); // (50 * 2) + (30 * 1)
@ -134,22 +137,21 @@ class CartManagementTest extends TestCase
$product1 = Product::factory()->create(); $product1 = Product::factory()->create();
$product2 = Product::factory()->create(); $product2 = Product::factory()->create();
CartItem::create([ $product1Price = ProductPrice::factory()->create([
'cart_id' => $cart->id, 'purchasable_id' => $product1->id,
'product_id' => $product1->id, 'purchasable_type' => get_class($product1),
'quantity' => 3, 'unit_amount' => 10.00,
'price' => 10.00,
'subtotal' => 30.00,
]); ]);
CartItem::create([ $product2Price = ProductPrice::factory()->create([
'cart_id' => $cart->id, 'purchasable_id' => $product2->id,
'product_id' => $product2->id, 'purchasable_type' => get_class($product2),
'quantity' => 2, 'unit_amount' => 20.00,
'price' => 20.00,
'subtotal' => 40.00,
]); ]);
$cart->addToCart($product1Price, quantity: 3);
$cart->addToCart($product2Price, quantity: 2);
$totalItems = $cart->fresh()->getTotalItems(); $totalItems = $cart->fresh()->getTotalItems();
$this->assertEquals(5, $totalItems); // 3 + 2 $this->assertEquals(5, $totalItems); // 3 + 2
@ -238,16 +240,16 @@ class CartManagementTest extends TestCase
$cart = Cart::create(); $cart = Cart::create();
$product = Product::factory()->create(['price' => 45.00]); $product = Product::factory()->create(['price' => 45.00]);
$cartItem = CartItem::create([ $productPrice = ProductPrice::factory()->create([
'cart_id' => $cart->id, 'purchasable_id' => $product->id,
'product_id' => $product->id, 'purchasable_type' => get_class($product),
'quantity' => 1, 'unit_amount' => 45.00,
'price' => $product->price,
'subtotal' => $product->price,
]); ]);
$cartItem = $cart->addToCart($productPrice, quantity: 1);
$this->assertEquals($cart->id, $cartItem->cart->id); $this->assertEquals($cart->id, $cartItem->cart->id);
$this->assertEquals($product->id, $cartItem->product->id); $this->assertEquals($productPrice->id, $cartItem->purchasable_id);
} }
/** @test */ /** @test */
@ -256,14 +258,14 @@ class CartManagementTest extends TestCase
$cart = Cart::create(); $cart = Cart::create();
$product = Product::factory()->create(['price' => 25.00]); $product = Product::factory()->create(['price' => 25.00]);
$cartItem = CartItem::create([ $productPrice = ProductPrice::factory()->create([
'cart_id' => $cart->id, 'purchasable_id' => $product->id,
'product_id' => $product->id, 'purchasable_type' => get_class($product),
'quantity' => 4, 'unit_amount' => 25.00,
'price' => $product->price,
'subtotal' => $product->price * 4,
]); ]);
$cartItem = $cart->addToCart($productPrice, quantity: 4);
$this->assertEquals(100.00, $cartItem->getSubtotal()); // 25 * 4 $this->assertEquals(100.00, $cartItem->getSubtotal()); // 25 * 4
} }
@ -273,20 +275,23 @@ class CartManagementTest extends TestCase
$cart = Cart::create(); $cart = Cart::create();
$product = Product::factory()->create(); $product = Product::factory()->create();
$cartItem = CartItem::create([ $productPrice = ProductPrice::factory()->create([
'cart_id' => $cart->id, 'purchasable_id' => $product->id,
'product_id' => $product->id, 'purchasable_type' => get_class($product),
'quantity' => 1, 'unit_amount' => 50.00,
'price' => 50.00,
'subtotal' => 50.00,
'attributes' => [
'color' => 'blue',
'size' => 'large',
],
]); ]);
$this->assertEquals('blue', $cartItem->attributes['color']); $cartItem = $cart->addToCart(
$this->assertEquals('large', $cartItem->attributes['size']); $productPrice,
quantity: 1,
parameters: [
'color' => 'blue',
'size' => 'large',
]
);
$this->assertEquals('blue', $cartItem->parameters['color']);
$this->assertEquals('large', $cartItem->parameters['size']);
} }
/** @test */ /** @test */
@ -295,23 +300,23 @@ class CartManagementTest extends TestCase
$cart = Cart::create(); $cart = Cart::create();
$product = Product::factory()->create(['price' => 30.00]); $product = Product::factory()->create(['price' => 30.00]);
CartItem::create([ $productPrice = ProductPrice::factory()->create([
'cart_id' => $cart->id, 'purchasable_id' => $product->id,
'product_id' => $product->id, 'purchasable_type' => get_class($product),
'quantity' => 1, 'unit_amount' => 30.00,
'price' => $product->price,
'subtotal' => $product->price,
'attributes' => ['size' => 'small'],
]); ]);
CartItem::create([ $cart->addToCart(
'cart_id' => $cart->id, $productPrice,
'product_id' => $product->id, quantity: 1,
'quantity' => 2, parameters: ['size' => 'small']
'price' => $product->price, );
'subtotal' => $product->price * 2,
'attributes' => ['size' => 'large'], $cart->addToCart(
]); $productPrice,
quantity: 2,
parameters: ['size' => 'large']
);
$this->assertCount(2, $cart->fresh()->items); $this->assertCount(2, $cart->fresh()->items);
} }
@ -322,14 +327,17 @@ class CartManagementTest extends TestCase
$cart = Cart::create(); $cart = Cart::create();
$product = Product::factory()->create(); $product = Product::factory()->create();
$cartItem = CartItem::create([ $productPrice = ProductPrice::factory()->create([
'cart_id' => $cart->id, 'purchasable_id' => $product->id,
'product_id' => $product->id, 'purchasable_type' => get_class($product),
'quantity' => 1, 'unit_amount' => 75.00,
'price' => 50.00,
'subtotal' => 50.00,
]); ]);
$cartItem = $cart->addToCart(
$productPrice,
quantity: 1,
);
$cartItemId = $cartItem->id; $cartItemId = $cartItem->id;
$cart->forceDelete(); $cart->forceDelete();

View File

@ -46,11 +46,11 @@ class ProductManagementTest extends TestCase
'manage_stock' => true, 'manage_stock' => true,
]); ]);
$this->assertTrue($product->increaseStock(10)); $this->assertTrue($product->increaseStock(60));
$this->assertEquals(60, $product->fresh()->stock_quantity); $this->assertEquals(60, $product->AvailableStocks);
$this->assertTrue($product->decreaseStock(5)); $this->assertTrue($product->decreaseStock(5));
$this->assertEquals(55, $product->fresh()->stock_quantity); $this->assertEquals(55, $product->AvailableStocks);
} }
/** @test */ /** @test */
@ -58,11 +58,11 @@ class ProductManagementTest extends TestCase
{ {
$product = Product::factory()->create([ $product = Product::factory()->create([
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 5,
]); ]);
$this->assertFalse($product->decreaseStock(10)); $this->assertThrows(function () use ($product) {
$this->assertEquals(5, $product->fresh()->stock_quantity); $product->decreaseStock(10);
}, \Blax\Shop\Exceptions\NotEnoughStockException::class);
} }
/** @test */ /** @test */
@ -70,10 +70,11 @@ class ProductManagementTest extends TestCase
{ {
$product = Product::factory()->create([ $product = Product::factory()->create([
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 100,
]); ]);
$this->assertEquals(100, $product->getAvailableStock()); $this->assertEquals(0, $product->getAvailableStock());
$product->increaseStock(20);
$this->assertEquals(20, $product->getAvailableStock());
} }
/** @test */ /** @test */
@ -81,14 +82,11 @@ class ProductManagementTest extends TestCase
{ {
$productInStock = Product::factory()->create([ $productInStock = Product::factory()->create([
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 10,
'in_stock' => true,
]); ]);
$productInStock->increaseStock(10);
$productOutOfStock = Product::factory()->create([ $productOutOfStock = Product::factory()->create([
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 0,
'in_stock' => false,
]); ]);
$this->assertTrue($productInStock->isInStock()); $this->assertTrue($productInStock->isInStock());
@ -128,7 +126,8 @@ class ProductManagementTest extends TestCase
$product = Product::factory()->create(); $product = Product::factory()->create();
ProductPrice::create([ ProductPrice::create([
'product_id' => $product->id, 'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'type' => 'one-time', 'type' => 'one-time',
'price' => 9999, 'price' => 9999,
'currency' => 'USD', 'currency' => 'USD',
@ -136,7 +135,8 @@ class ProductManagementTest extends TestCase
]); ]);
ProductPrice::create([ ProductPrice::create([
'product_id' => $product->id, 'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'type' => 'recurring', 'type' => 'recurring',
'price' => 1999, 'price' => 1999,
'currency' => 'USD', 'currency' => 'USD',
@ -144,7 +144,7 @@ class ProductManagementTest extends TestCase
'active' => true, 'active' => true,
]); ]);
$this->assertCount(2, $product->fresh()->prices); $this->assertCount(2, $product->prices);
} }
/** @test */ /** @test */
@ -202,21 +202,20 @@ class ProductManagementTest extends TestCase
public function it_can_scope_in_stock_products() public function it_can_scope_in_stock_products()
{ {
Product::factory()->create([ Product::factory()->create([
'in_stock' => true, 'manage_stock' => false,
'manage_stock' => true,
'stock_quantity' => 10,
]); ]);
Product::factory()->create([ $productInStock = Product::factory()->create([
'in_stock' => false,
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 0,
]); ]);
$productInStock->increaseStock(10);
$inStock = Product::inStock()->get(); $inStock = Product::inStock()->get();
$this->assertCount(1, $inStock); $this->assertCount(2, $inStock);
$this->assertTrue($inStock->first()->in_stock); $this->assertTrue((bool) ($inStock->first()->isInStock()));
$this->assertNotEquals($inStock->reverse()->first()->id, $inStock->first()->id);
$this->assertTrue((bool) ($inStock->reverse()->first()->isInStock()));
} }
/** @test */ /** @test */

View File

@ -2,9 +2,12 @@
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem;
use Blax\Shop\Tests\TestCase; use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User; use Workbench\App\Models\User;
@ -18,17 +21,23 @@ class PurchaseFlowTest extends TestCase
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product = Product::factory()->create([ $product = Product::factory()->create([
'price' => 99.99,
'manage_stock' => false, 'manage_stock' => false,
]); ]);
$purchase = $user->purchase($product, quantity: 1); $price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'amount' => 4999, // in cents
'currency' => 'USD',
]);
$purchase = $user->purchase($price, quantity: 1);
$this->assertInstanceOf(ProductPurchase::class, $purchase); $this->assertInstanceOf(ProductPurchase::class, $purchase);
$this->assertEquals($product->id, $purchase->product_id); $this->assertEquals($product->id, $purchase->purchasable_id);
$this->assertEquals($user->id, $purchase->user_id); $this->assertEquals($user->id, $purchase->purchaser_id);
$this->assertEquals(1, $purchase->quantity); $this->assertEquals(1, $purchase->quantity);
$this->assertEquals('completed', $purchase->status); $this->assertEquals('unpaid', $purchase->status);
} }
/** @test */ /** @test */
@ -36,27 +45,36 @@ class PurchaseFlowTest extends TestCase
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product = Product::factory()->create([ $product = Product::factory()->create([
'price' => 49.99,
'manage_stock' => false, 'manage_stock' => false,
]); ]);
$cartItem = $user->addToCart($product, quantity: 2); $price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'amount' => 2999, // in cents
'currency' => 'USD',
'is_default' => true,
]);
$this->assertInstanceOf(ProductPurchase::class, $cartItem); $cartItem = $user->addToCart($price, quantity: 2);
$this->assertEquals('cart', $cartItem->status);
$this->assertInstanceOf(CartItem::class, $cartItem);
$this->assertEquals(2, $cartItem->quantity); $this->assertEquals(2, $cartItem->quantity);
$this->assertEquals($product->id, $cartItem->product_id); $this->assertEquals($price->id, $cartItem->purchasable_id);
} }
/** @test */ /** @test */
public function user_can_get_cart_items() public function user_can_get_cart_items()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product1 = Product::factory()->create(['price' => 20.00]); $product1 = Product::factory()->withPrices()->create();
$product2 = Product::factory()->create(['price' => 30.00]); $product2 = Product::factory()->withPrices(2)->create();
$user->addToCart($product1, quantity: 1); $this->assertCount(1, $product1->prices);
$user->addToCart($product2, quantity: 2); $this->assertCount(2, $product2->prices);
$user->addToCart($product1->prices()->first(), quantity: 1);
$user->addToCart($product2->prices()->first(), quantity: 2);
$cartItems = $user->cartItems; $cartItems = $user->cartItems;
@ -67,12 +85,9 @@ class PurchaseFlowTest extends TestCase
public function user_can_update_cart_item_quantity() public function user_can_update_cart_item_quantity()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product = Product::factory()->create([ $product = Product::factory()->withPrices()->create();
'price' => 50.00,
'manage_stock' => false,
]);
$cartItem = $user->addToCart($product, quantity: 1); $cartItem = $user->addToCart($product->prices()->first(), quantity: 1);
$user->updateCartQuantity($cartItem, quantity: 5); $user->updateCartQuantity($cartItem, quantity: 5);
@ -83,32 +98,32 @@ class PurchaseFlowTest extends TestCase
public function user_can_remove_item_from_cart() public function user_can_remove_item_from_cart()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product = Product::factory()->create(); $product = Product::factory()->withPrices()->create();
$cartItem = $user->addToCart($product, quantity: 1); $cartItem = $user->addToCart($product->prices()->first(), quantity: 1);
$this->assertCount(1, $user->fresh()->cartItems);
$this->assertCount(1, $user->cartItems);
$user->removeFromCart($cartItem); $user->removeFromCart($cartItem);
$this->assertCount(0, $user->fresh()->cartItems); $this->assertCount(0, $user->refresh()->cartItems);
} }
/** @test */ /** @test */
public function user_can_checkout_cart() public function user_can_checkout_cart()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product1 = Product::factory()->create([ $product1 = Product::factory()->withPrices()->create();
'price' => 25.00, $product2 = Product::factory()->withPrices()->create();
'manage_stock' => false,
]);
$product2 = Product::factory()->create([
'price' => 35.00,
'manage_stock' => false,
]);
$user->addToCart($product1, quantity: 2); $user->addToCart($product1, quantity: 2);
$user->addToCart($product2, quantity: 1); $user->addToCart($product2, quantity: 1);
$this->assertThrows(fn() => $user->checkout(), NotEnoughStockException::class);
$product1->update(['manage_stock' => false]);
$product2->increaseStock(5);
$purchases = $user->checkout(); $purchases = $user->checkout();
$this->assertCount(2, $purchases); $this->assertCount(2, $purchases);