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]": {
"editor.defaultFormatter": "bmewburn.vscode-intelephense-client"
},

View File

@ -68,4 +68,25 @@ class ProductFactory extends Factory
{
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'))) {
Schema::create(config('shop.tables.product_prices', 'product_prices'), function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('product_id');
$table->uuidMorphs('purchasable');
$table->string('stripe_price_id')->nullable();
$table->string('name')->nullable();
$table->string('type')->default('one_time'); // one_time, recurring
$table->string('currency', 3)->default('USD');
$table->integer('price')->default(0); // Store as smallest currency unit (cents)
$table->integer('sale_price')->nullable();
$table->string('currency', 3)->default('EUR');
$table->integer('unit_amount')->default(0); // Store as smallest currency unit (cents)
$table->integer('sale_unit_amount')->nullable();
$table->boolean('is_default')->default(false);
$table->boolean('active')->default(true);
$table->string('billing_scheme')->nullable(); // per_unit, tiered
@ -132,10 +132,7 @@ return new class extends Migration
$table->json('meta')->nullable();
$table->timestamps();
$table->index(['product_id', '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');
$table->index('currency');
});
}
@ -271,18 +268,17 @@ return new class extends Migration
Schema::create(config('shop.tables.cart_items', 'cart_items'), function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('cart_id');
$table->uuid('product_id');
$table->uuidMorphs('purchasable');
$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('subtotal', 10, 2);
$table->json('attributes')->nullable();
$table->json('parameters')->nullable();
$table->json('meta')->nullable();
$table->timestamps();
$table->index(['cart_id', 'product_id']);
$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 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;
use Blax\Shop\Contracts\Cartable;
use Blax\Workkit\Traits\HasExpiration;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
@ -109,4 +110,30 @@ class Cart extends Model
$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;
use Blax\Workkit\Traits\HasMeta;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CartItem extends Model
{
use HasUuids;
use HasUuids, HasMeta;
protected $fillable = [
'cart_id',
'product_id',
'purchasable_id',
'purchasable_type',
'quantity',
'price',
'regular_price',
'subtotal',
'attributes',
'parameters',
'meta',
];
@ -26,7 +28,7 @@ class CartItem extends Model
'price' => 'decimal:2',
'regular_price' => 'decimal:2',
'subtotal' => 'decimal:2',
'attributes' => 'array',
'parameters' => 'array',
'meta' => 'array',
];
@ -59,9 +61,18 @@ class CartItem extends Model
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

View File

@ -3,19 +3,22 @@
namespace Blax\Shop\Models;
use App\Services\StripeService;
use Blax\Shop\Contracts\Cartable;
use Blax\Workkit\Traits\HasMetaTranslation;
use Blax\Shop\Events\ProductCreated;
use Blax\Shop\Events\ProductUpdated;
use Blax\Shop\Contracts\Purchasable;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class Product extends Model implements Purchasable
class Product extends Model implements Purchasable, Cartable
{
use HasFactory, HasUuids, HasMetaTranslation;
@ -49,7 +52,6 @@ class Product extends Model implements Purchasable
protected $casts = [
'manage_stock' => 'boolean',
'in_stock' => 'boolean',
'virtual' => 'boolean',
'downloadable' => 'boolean',
'meta' => 'object',
@ -62,8 +64,6 @@ class Product extends Model implements Purchasable
'sort_order' => 'integer',
];
// Remove - causes issues with casting
protected $dispatchesEvents = [
'created' => ProductCreated::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()
@ -163,9 +166,17 @@ class Product extends Model implements Purchasable
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)
@ -180,10 +191,6 @@ class Product extends Model implements Purchasable
public function isOnSale(): bool
{
if (!$this->sale_price) {
return false;
}
$now = now();
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;
}
public function isInStock(): bool
{
if (!$this->manage_stock) {
return true;
}
return $this->getAvailableStock() > 0;
}
public function decreaseStock(int $quantity = 1): bool
{
if (!$this->manage_stock) {
return true;
}
if (config('shop.stock.log_changes', true)) {
$this->logStockChange(-$quantity, 'decrease');
if ($this->AvailableStocks < $quantity) {
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();
return true;
}
public function increaseStock(int $quantity = 1): void
public function increaseStock(int $quantity = 1): bool
{
if (!$this->manage_stock) {
return;
return false;
}
$this->stocks()->create([
'quantity' => $quantity,
'type' => 'increase',
'status' => 'completed',
]);
$this->logStockChange($quantity, 'increase');
$this->save();
return true;
}
public function reserveStock(
@ -257,7 +289,7 @@ class Product extends Model implements Purchasable
return PHP_INT_MAX;
}
return max(0, $this->stock_quantity);
return max(0, $this->AvailableStocks);
}
public function getReservedStock(): int
@ -315,6 +347,17 @@ class Product extends Model implements Purchasable
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)
{
return $query->where('is_visible', true)
@ -399,9 +442,6 @@ class Product extends Model implements Purchasable
'regular_price' => $this->regular_price,
'sale_price' => $this->sale_price,
'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(),
'featured' => $this->featured,
'virtual' => $this->virtual,
@ -456,4 +496,9 @@ class Product extends Model implements Purchasable
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;
use Blax\Shop\Contracts\Cartable;
use Blax\Shop\Contracts\Purchasable;
use Blax\Workkit\Traits\HasMetaTranslation;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ProductPrice extends Model
class ProductPrice extends Model implements Cartable
{
use HasUuids, HasMetaTranslation;
use HasFactory, HasUuids, HasMetaTranslation;
protected $fillable = [
'active',
'product_id',
'purchasable_type',
'purchasable_id',
'stripe_price_id',
'name',
'type',
'price',
'sale_price',
'currency',
'unit_amount',
'sale_unit_amount',
'is_default',
'active',
'billing_scheme',
'interval',
'interval_count',
'trial_period_days',
'currency',
'meta',
];
protected $casts = [
'price' => 'integer',
'sale_price' => 'integer',
'is_default' => 'boolean',
'trial_period_days' => 'integer',
'meta' => 'object',
'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)

View File

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

View File

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

View File

@ -2,31 +2,43 @@
namespace Blax\Shop\Traits;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Models\CartItem;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Collection;
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
*/
public function purchases(): MorphMany
{
// This morph represents the purchaser (e.g. User), not the product.
return $this->morphMany(
config('shop.models.product_purchase', ProductPurchase::class),
'purchasable'
'purchaser'
);
}
/**
* 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,
int $quantity = 1,
): 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");
}
$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
if ($product->manage_stock) {
@ -81,7 +98,10 @@ trait HasShoppingCapabilities
// Create purchase record
$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,
'status' => 'unpaid',
'meta' => array_merge([
@ -96,64 +116,48 @@ trait HasShoppingCapabilities
'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;
}
/**
* Add product to cart
*
* @param Product $product
* @param Product|ProductPrice $price
* @param int $quantity
* @param array $options
* @return ProductPurchase
* @return CartItem
* @throws \Exception
*/
public function addToCart(Product $product, int $quantity = 1, array $options = []): ProductPurchase
{
// Check if product already in cart
$existingItem = $this->cartItems()
->where('product_id', $product->id)
->first();
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'] ?? []),
]);
public function addToCart(Product|ProductPrice $price, int $quantity = 1, array $parameters = []): CartItem
{
return $this->cart()->latest()->firstOrCreate()->addToCart(
$price,
$quantity,
$parameters
);
}
/**
* Update cart item quantity
*
* @param ProductPurchase $cartItem
* @param CartItem $cartItem
* @param int $quantity
* @return ProductPurchase
* @return CartItem
* @throws \Exception
*/
public function updateCartQuantity(ProductPurchase $cartItem, int $quantity): ProductPurchase
public function updateCartQuantity(CartItem $cartItem, int $quantity): CartItem
{
if ($cartItem->status !== 'cart') {
throw new \Exception("Cannot update non-cart item");
}
$product = $cartItem->product;
$product = $cartItem->purchasable;
// Validate stock
if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
@ -161,15 +165,9 @@ trait HasShoppingCapabilities
}
$meta = (array) $cartItem->meta;
$priceId = $meta['price_id'] ?? null;
$price = $this->determinePurchasePrice($product, $priceId);
$cartItem->update([
'quantity' => $quantity,
'meta' => array_merge($meta, [
'price' => $price,
'amount' => $price * $quantity,
]),
]);
return $cartItem->fresh();
@ -178,17 +176,13 @@ trait HasShoppingCapabilities
/**
* Remove item from cart
*
* @param ProductPurchase $cartItem
* @param CartItem $cartItem
* @return bool
* @throws \Exception
*/
public function removeFromCart(ProductPurchase $cartItem): bool
public function removeFromCart(CartItem $cartItem): bool
{
if ($cartItem->status !== 'cart') {
throw new \Exception("Cannot remove non-cart item");
}
return $cartItem->delete();
return $cartItem->forceDelete();
}
/**
@ -237,56 +231,19 @@ trait HasShoppingCapabilities
*/
public function checkout(?string $cartId = null, array $options = []): Collection
{
$items = $this->cartItems()->with('product')->get();
$items = $this->cartItems()
->with('purchasable')
->get();
if ($items->isEmpty()) {
throw new \Exception("Cart is empty");
}
// Validate stock for all items
foreach ($items as $item) {
$product = $item->product;
if ($product->manage_stock && $product->getAvailableStock() < $item->quantity) {
throw new \Exception("Insufficient stock for: {$product->getLocalized('name')}");
}
}
$purchases = collect();
// Process each item
$completedPurchases = collect();
foreach ($items as $item) {
$product = $item->product;
//
// Decrease stock
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;
return $purchases;
}
/**

View File

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

View File

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

View File

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