diff --git a/.vscode/settings.json b/.vscode/settings.json index 977478f..77b3c43 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "editor.formatOnSave": true, + "editor.formatOnSave": false, "[php]": { "editor.defaultFormatter": "bmewburn.vscode-intelephense-client" }, diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index 8d7d1b9..0088b25 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -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(); + } + }); + } } diff --git a/database/factories/ProductPriceFactory.php b/database/factories/ProductPriceFactory.php new file mode 100644 index 0000000..c6fa51b --- /dev/null +++ b/database/factories/ProductPriceFactory.php @@ -0,0 +1,21 @@ + $this->faker->randomFloat(2, 1, 1000), + 'currency' => 'EUR', + 'is_default' => false, + ]; + } +} diff --git a/database/migrations/create_blax_shop_tables.php.stub b/database/migrations/create_blax_shop_tables.php.stub index b9dd936..a5e3e02 100644 --- a/database/migrations/create_blax_shop_tables.php.stub +++ b/database/migrations/create_blax_shop_tables.php.stub @@ -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'); }); } diff --git a/src/Contracts/Cartable.php b/src/Contracts/Cartable.php new file mode 100644 index 0000000..57a666c --- /dev/null +++ b/src/Contracts/Cartable.php @@ -0,0 +1,7 @@ +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; + } } diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index 1b37e92..b245a72 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -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 diff --git a/src/Models/Product.php b/src/Models/Product.php index 28e14c9..a2400d1 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -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); + } } diff --git a/src/Models/ProductPrice.php b/src/Models/ProductPrice.php index ebcc7c7..ac0b539 100644 --- a/src/Models/ProductPrice.php +++ b/src/Models/ProductPrice.php @@ -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) diff --git a/src/Models/ProductPurchase.php b/src/Models/ProductPurchase.php index b226b7a..4bb1bc4 100644 --- a/src/Models/ProductPurchase.php +++ b/src/Models/ProductPurchase.php @@ -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() diff --git a/src/Models/ProductStock.php b/src/Models/ProductStock.php index b7458b2..49c82f3 100644 --- a/src/Models/ProductStock.php +++ b/src/Models/ProductStock.php @@ -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'); + } } diff --git a/src/Traits/HasShoppingCapabilities.php b/src/Traits/HasShoppingCapabilities.php index a570502..cf9d34f 100644 --- a/src/Traits/HasShoppingCapabilities.php +++ b/src/Traits/HasShoppingCapabilities.php @@ -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; } /** diff --git a/tests/Feature/CartManagementTest.php b/tests/Feature/CartManagementTest.php index d3188ec..e7f3c0c 100644 --- a/tests/Feature/CartManagementTest.php +++ b/tests/Feature/CartManagementTest.php @@ -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(); diff --git a/tests/Feature/ProductManagementTest.php b/tests/Feature/ProductManagementTest.php index 9b4c7ce..42c1cc2 100644 --- a/tests/Feature/ProductManagementTest.php +++ b/tests/Feature/ProductManagementTest.php @@ -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 */ diff --git a/tests/Feature/PurchaseFlowTest.php b/tests/Feature/PurchaseFlowTest.php index d85514e..d359f8e 100644 --- a/tests/Feature/PurchaseFlowTest.php +++ b/tests/Feature/PurchaseFlowTest.php @@ -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);