This commit is contained in:
a6a2f5842 2025-11-21 11:49:41 +01:00
commit d610cc5717
59 changed files with 7493 additions and 0 deletions

2
.dockerignore Normal file
View File

@ -0,0 +1,2 @@
docker
.vscode

1
.envrc Normal file
View File

@ -0,0 +1 @@
use nix

6
.gitattributes vendored Normal file
View File

@ -0,0 +1,6 @@
/.vscode export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/phpunit.xml.dist export-ignore
/docs export-ignore
/tests export-ignore

39
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,39 @@
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: [8.1, 8.2, 8.3]
laravel: [10.*, 11.*]
exclude:
- php: 8.1
laravel: 11.*
name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite
coverage: none
- name: Install dependencies
run: |
composer require "illuminate/support:${{ matrix.laravel }}" --no-update
composer update --prefer-dist --no-interaction --no-progress
- name: Execute tests
run: vendor/bin/phpunit

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
reports
sandbox
vendor
composer.lock
.idea/
workbench
.phpunit.result.cache

File diff suppressed because one or more lines are too long

6
.vscode/settings.json vendored Normal file
View File

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

162
README.md Normal file
View File

@ -0,0 +1,162 @@
# Laravel Shop Package
A comprehensive headless e-commerce package for Laravel with multi-currency support, stock management, Stripe integration, and product actions.
## Features
- 🛍️ **Product Management** - Simple, variable, grouped, and external products
- 💰 **Multi-Currency Support** - Handle multiple currencies with ease
- 📦 **Advanced Stock Management** - Stock reservations, low stock alerts, and backorders
- 💳 **Stripe Integration** - Built-in Stripe product and price synchronization
- 🎯 **Product Actions** - Execute custom actions on product events (purchases, refunds)
- 🔗 **Product Relations** - Related products, upsells, and cross-sells
- 🌍 **Translation Ready** - Built-in meta translation support
- 📊 **Stock Logging** - Complete audit trail of stock changes
- 🎨 **Headless Architecture** - Perfect for API-first applications
- ⚡ **Caching Support** - Built-in cache management for better performance
- 🛒 **Shopping Capabilities** - Built-in trait for any purchaser model
## Installation
```bash
composer require blax-software/laravel-shop
```
Publish the configuration:
```bash
php artisan vendor:publish --provider="Blax\Shop\ShopServiceProvider"
```
Run migrations:
```bash
php artisan migrate
```
## Quick Start
### Setup Your User Model
Add the `HasShoppingCapabilities` trait to any model that should be able to purchase products (typically your User model):
```php
use Blax\Shop\Traits\HasShoppingCapabilities;
class User extends Authenticatable
{
use HasShoppingCapabilities;
// ...existing code...
}
```
### Creating Your First Product
```php
use Blax\Shop\Models\Product;
$product = Product::create([
'slug' => 'amazing-t-shirt',
'sku' => 'TSH-001',
'type' => 'simple',
'price' => 29.99,
'regular_price' => 29.99,
'manage_stock' => true,
'stock_quantity' => 100,
'status' => 'published',
]);
// Add translated name
$product->setLocalized('name', 'Amazing T-Shirt', 'en');
$product->setLocalized('description', 'A comfortable cotton t-shirt', 'en');
```
### Purchasing a Product
```php
use Blax\Shop\Models\Product;
$product = Product::find($productId);
$user = auth()->user();
// Simple purchase
$purchase = $user->purchase($product, quantity: 1);
// Purchase with options
$purchase = $user->purchase($product, quantity: 2, options: [
'price_id' => $priceId,
'charge_id' => $paymentIntent->id,
]);
// Add to cart
$cartItem = $user->addToCart($product, quantity: 1);
// Checkout cart
$completedPurchases = $user->checkout();
// Check if user has purchased
if ($user->hasPurchased($product)) {
// Grant access
}
```
## Documentation
- [Product Management](docs/01-products.md)
- [Stripe Integration](docs/02-stripe.md)
- [Purchasing Products](docs/03-purchasing.md)
- [Subscriptions](docs/04-subscriptions.md)
- [Stock Management](docs/05-stock.md)
- [API Usage](docs/06-api.md)
## Configuration
The `config/shop.php` file contains all configuration options:
```php
return [
'tables' => [
'products' => 'products',
'product_categories' => 'product_categories',
// ...
],
'stripe' => [
'enabled' => env('SHOP_STRIPE_ENABLED', false),
'sync_prices' => env('SHOP_STRIPE_SYNC_PRICES', true),
],
'stock' => [
'allow_backorders' => env('SHOP_ALLOW_BACKORDERS', false),
'log_changes' => env('SHOP_LOG_STOCK_CHANGES', true),
],
'cache' => [
'enabled' => env('SHOP_CACHE_ENABLED', true),
'prefix' => 'shop:',
],
];
```
## Commands
### Reinstall Shop Tables
```bash
# With confirmation
php artisan shop:reinstall
# Force without confirmation
php artisan shop:reinstall --force
```
⚠️ **Warning:** This will delete all shop data!
## License
MIT License
## Support
For issues and questions, please use the [GitHub issue tracker](https://github.com/blax/laravel-shop/issues).

59
composer.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "blax-software/laravel-shop",
"description": "A comprehensive e-commerce package for Laravel",
"type": "library",
"license": "MIT",
"autoload": {
"psr-4": {
"Blax\\Shop\\": "src/",
"Blax\\Shop\\Database\\Factories\\": "database/factories/"
}
},
"autoload-dev": {
"psr-4": {
"Blax\\Shop\\Tests\\": "tests/",
"Workbench\\App\\": "workbench/app/",
"Workbench\\Database\\Factories\\": "workbench/database/factories/",
"Workbench\\Database\\Seeders\\": "workbench/database/seeders/"
}
},
"require": {
"php": "^8.1",
"illuminate/support": "^10.0|^11.0",
"illuminate/database": "^10.0|^11.0",
"blax-software/laravel-workkit": "dev-master"
},
"require-dev": {
"orchestra/testbench": "^8.0|^9.0",
"phpunit/phpunit": "^10.0",
"mockery/mockery": "^1.5"
},
"scripts": {
"test": "vendor/bin/phpunit",
"test-coverage": "vendor/bin/phpunit --coverage-html coverage",
"post-autoload-dump": [
"@clear",
"@prepare"
],
"clear": "@php vendor/bin/testbench package:purge-skeleton --ansi",
"prepare": "@php vendor/bin/testbench package:discover --ansi",
"build": "@php vendor/bin/testbench workbench:build --ansi",
"serve": [
"Composer\\Config::disableProcessTimeout",
"@build",
"@php vendor/bin/testbench serve --ansi"
],
"lint": [
"pint"
]
},
"extra": {
"laravel": {
"providers": [
"Blax\\Shop\\ShopServiceProvider"
]
}
},
"minimum-stability": "dev",
"prefer-stable": true
}

84
config/shop.php Normal file
View File

@ -0,0 +1,84 @@
<?php
return [
// Table names (customizable for multi-tenancy)
'tables' => [
'products' => 'products',
'product_prices' => 'product_prices',
'product_categories' => 'product_categories',
'product_images' => 'product_images',
'product_attributes' => 'product_attributes',
'product_purchases' => 'product_purchases',
'product_stocks' => 'product_stocks',
'carts' => 'carts',
'cart_items' => 'cart_items',
],
// Model classes (allow overriding in main instance)
'models' => [
'product' => \Blax\Shop\Models\Product::class,
'product_price' => \Blax\Shop\Models\ProductPrice::class,
'product_category' => \Blax\Shop\Models\ProductCategory::class,
'product_stock' => \Blax\Shop\Models\ProductStock::class,
'product_attribute' => \Blax\Shop\Models\ProductAttribute::class,
'cart' => \Blax\Shop\Models\Cart::class,
'cart_item' => \Blax\Shop\Models\CartItem::class,
],
// API Routes configuration
'routes' => [
'enabled' => true,
'prefix' => 'api/shop',
'middleware' => ['api'],
'name_prefix' => 'shop.',
],
// Stock management
'stock' => [
'track_inventory' => true,
'allow_backorders' => false,
'low_stock_threshold' => 5,
'log_changes' => true,
'auto_release_expired' => true,
],
// Product actions (extensible by main instance)
'actions' => [
'path' => app_path('Jobs/ProductAction'),
'namespace' => 'App\\Jobs\\ProductAction',
'auto_discover' => true,
],
// Stripe integration (optional)
'stripe' => [
'enabled' => env('SHOP_STRIPE_ENABLED', false),
'sync_prices' => true,
],
// Cache configuration
'cache' => [
'enabled' => env('SHOP_CACHE_ENABLED', true),
'ttl' => 3600,
'prefix' => 'shop:',
],
// Pagination
'pagination' => [
'per_page' => 20,
'max_per_page' => 100,
],
// Cart configuration
'cart' => [
'expire_after_days' => 30,
'auto_cleanup' => true,
'merge_on_login' => true,
],
// API Response format
'api' => [
'include_meta' => true,
'wrap_response' => true,
'response_key' => 'data',
],
];

View File

@ -0,0 +1,47 @@
<?php
namespace Blax\Shop\Database\Factories;
use Blax\Shop\Models\Order;
use Illuminate\Database\Eloquent\Factories\Factory;
class OrderFactory extends Factory
{
protected $model = Order::class;
public function definition(): array
{
return [
'order_number' => 'ORD-' . strtoupper($this->faker->bothify('####-????')),
'customer_id' => null,
'customer_email' => $this->faker->safeEmail(),
'customer_first_name' => $this->faker->firstName(),
'customer_last_name' => $this->faker->lastName(),
'status' => 'pending',
'payment_status' => 'pending',
'subtotal' => 0,
'tax_total' => 0,
'shipping_total' => 0,
'discount_total' => 0,
'total' => 0,
'currency' => 'USD',
'payment_method' => 'stripe',
];
}
public function completed(): static
{
return $this->state([
'status' => 'completed',
'payment_status' => 'paid',
]);
}
public function cancelled(): static
{
return $this->state([
'status' => 'cancelled',
'payment_status' => 'failed',
]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Blax\Shop\Database\Factories;
use Blax\Shop\Models\ProductCategory;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class ProductCategoryFactory extends Factory
{
protected $model = ProductCategory::class;
public function definition(): array
{
$name = $this->faker->words(2, true);
return [
'name' => $name,
'slug' => Str::slug($name),
'is_visible' => true,
'sort_order' => $this->faker->numberBetween(0, 100),
'meta' => json_encode(new \stdClass()),
];
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace Blax\Shop\Database\Factories;
use Blax\Shop\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class ProductFactory extends Factory
{
protected $model = Product::class;
public function definition(): array
{
$name = $this->faker->words(3, true);
return [
'slug' => Str::slug($name),
'sku' => strtoupper($this->faker->bothify('??-####')),
'type' => 'simple',
'status' => 'published',
'visible' => true,
'featured' => false,
'price' => $this->faker->randomFloat(2, 10, 1000),
'regular_price' => $this->faker->randomFloat(2, 10, 1000),
'manage_stock' => true,
'stock_quantity' => $this->faker->numberBetween(0, 100),
'in_stock' => true,
'stock_status' => 'instock',
'published_at' => now(),
'meta' => json_encode(new \stdClass()),
];
}
public function onSale(): static
{
return $this->state(function (array $attributes) {
$regularPrice = $attributes['regular_price'];
return [
'sale_price' => $regularPrice * 0.8,
'sale_start' => now()->subDay(),
'sale_end' => now()->addWeek(),
];
});
}
public function outOfStock(): static
{
return $this->state([
'stock_quantity' => 0,
'in_stock' => false,
'stock_status' => 'outofstock',
]);
}
public function variable(): static
{
return $this->state(['type' => 'variable']);
}
public function draft(): static
{
return $this->state(['status' => 'draft']);
}
public function featured(): static
{
return $this->state(['featured' => true]);
}
}

View File

@ -0,0 +1,299 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Products table
if (!Schema::hasTable(config('shop.tables.products', 'products'))) {
Schema::create(config('shop.tables.products', 'products'), function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('slug')->unique();
$table->string('sku')->nullable()->unique();
$table->string('type')->default('simple'); // simple, variable, grouped, external
$table->string('stripe_product_id')->nullable();
$table->decimal('price', 10, 2)->default(0);
$table->decimal('regular_price', 10, 2)->nullable();
$table->decimal('sale_price', 10, 2)->nullable();
$table->timestamp('sale_start')->nullable();
$table->timestamp('sale_end')->nullable();
$table->boolean('manage_stock')->default(false);
$table->integer('stock_quantity')->default(0);
$table->integer('low_stock_threshold')->nullable();
$table->boolean('in_stock')->default(true);
$table->string('stock_status')->default('instock'); // instock, outofstock, onbackorder
$table->decimal('weight', 10, 2)->nullable();
$table->decimal('length', 10, 2)->nullable();
$table->decimal('width', 10, 2)->nullable();
$table->decimal('height', 10, 2)->nullable();
$table->boolean('virtual')->default(false);
$table->boolean('downloadable')->default(false);
$table->uuid('parent_id')->nullable();
$table->boolean('featured')->default(false);
$table->boolean('visible')->default(true);
$table->string('status')->default('draft'); // draft, published, archived
$table->timestamp('published_at')->nullable();
$table->integer('sort_order')->default(0);
$table->json('meta')->default('{}');
$table->string('tax_class')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['slug', 'status']);
$table->index(['featured', 'visible', 'status']);
$table->index('parent_id');
$table->foreign('parent_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
});
} else {
// Add new fields to existing products table
Schema::table(config('shop.tables.products', 'products'), function (Blueprint $table) {
if (!Schema::hasColumn(config('shop.tables.products', 'products'), 'low_stock_threshold')) {
$table->integer('low_stock_threshold')->nullable()->after('stock_quantity');
}
if (!Schema::hasColumn(config('shop.tables.products', 'products'), 'published_at')) {
$table->timestamp('published_at')->nullable()->after('status');
}
if (!Schema::hasColumn(config('shop.tables.products', 'products'), 'sort_order')) {
$table->integer('sort_order')->default(0)->after('published_at');
}
});
}
// Product categories table
if (!Schema::hasTable(config('shop.tables.product_categories', 'product_categories'))) {
Schema::create(config('shop.tables.product_categories', 'product_categories'), function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('slug')->unique();
$table->uuid('parent_id')->nullable();
$table->integer('sort_order')->default(0);
$table->boolean('visible')->default(true);
$table->json('meta')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['parent_id', 'visible']);
$table->foreign('parent_id')->references('id')->on(config('shop.tables.product_categories', 'product_categories'))->onDelete('cascade');
});
}
// Product category pivot table
if (!Schema::hasTable(config('shop.tables.product_category_product', 'product_category_product'))) {
Schema::create(config('shop.tables.product_category_product', 'product_category_product'), function (Blueprint $table) {
$table->uuid('product_id');
$table->uuid('product_category_id');
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->primary(['product_id', 'product_category_id'], 'product_category_product_primary');
$table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
$table->foreign('product_category_id')->references('id')->on(config('shop.tables.product_categories', 'product_categories'))->onDelete('cascade');
});
}
// Product prices table (multi-currency support)
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->string('currency', 3)->default('USD');
$table->decimal('price', 10, 2);
$table->string('stripe_price_id')->nullable();
$table->boolean('is_default')->default(false);
$table->timestamps();
$table->index(['product_id', 'currency']);
$table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
});
}
// Product attributes table
if (!Schema::hasTable(config('shop.tables.product_attributes', 'product_attributes'))) {
Schema::create(config('shop.tables.product_attributes', 'product_attributes'), function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('product_id');
$table->string('key');
$table->text('value')->nullable();
$table->string('type')->default('text'); // text, select, color, image
$table->integer('sort_order')->default(0);
$table->json('meta')->nullable();
$table->timestamps();
$table->index(['product_id', 'key']);
$table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
});
}
// Product stocks table (reservations)
if (!Schema::hasTable(config('shop.tables.product_stocks', 'product_stocks'))) {
Schema::create(config('shop.tables.product_stocks', 'product_stocks'), function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('product_id');
$table->integer('quantity');
$table->string('type')->default('reservation'); // reservation, adjustment, sale, return
$table->string('status')->default('pending'); // pending, completed, cancelled, expired
$table->string('reference_type')->nullable();
$table->string('reference_id')->nullable();
$table->timestamp('expires_at')->nullable();
$table->text('note')->nullable();
$table->timestamps();
$table->index(['product_id', 'status']);
$table->index(['reference_type', 'reference_id']);
$table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
});
}
// Product stock logs table
if (!Schema::hasTable(config('shop.tables.product_stock_logs', 'product_stock_logs'))) {
Schema::create(config('shop.tables.product_stock_logs', 'product_stock_logs'), function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('product_id');
$table->integer('quantity_change');
$table->integer('quantity_after');
$table->string('type'); // increase, decrease, adjustment
$table->string('reference_type')->nullable();
$table->string('reference_id')->nullable();
$table->text('note')->nullable();
$table->timestamps();
$table->index(['product_id', 'created_at']);
$table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
});
}
// Product relations table (related, upsell, cross-sell)
if (!Schema::hasTable(config('shop.tables.product_relations', 'product_relations'))) {
Schema::create(config('shop.tables.product_relations', 'product_relations'), function (Blueprint $table) {
$table->uuid('product_id');
$table->uuid('related_product_id');
$table->string('type')->default('related'); // related, upsell, cross-sell
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->primary(['product_id', 'related_product_id', 'type'], 'product_relations_primary');
$table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
$table->foreign('related_product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
$table->index(['product_id', 'type']);
});
}
// Product actions table
if (!Schema::hasTable(config('shop.tables.product_actions', 'product_actions'))) {
Schema::create(config('shop.tables.product_actions', 'product_actions'), function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('product_id');
$table->string('action_type');
$table->string('event')->default('purchased'); // purchased, refunded, etc.
$table->json('config')->nullable();
$table->boolean('active')->default(true);
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->index(['product_id', 'event', 'active']);
$table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
});
}
// Product purchases table
if (!Schema::hasTable(config('shop.tables.product_purchases', 'product_purchases'))) {
Schema::create(config('shop.tables.product_purchases', 'product_purchases'), function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('product_id');
$table->morphs('purchasable');
$table->string('status')->default('pending');
$table->integer('quantity')->default(1);
$table->json('meta')->nullable();
$table->timestamps();
$table->index(['product_id', 'status']);
$table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
});
}
// Carts table
if (!Schema::hasTable(config('shop.tables.carts', 'carts'))) {
Schema::create(config('shop.tables.carts', 'carts'), function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('session_id')->nullable()->unique();
$table->nullableMorphs('customer');
$table->string('currency', 3)->default('USD');
$table->string('status')->default('active'); // active, abandoned, converted, expired
$table->timestamp('last_activity_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('converted_at')->nullable();
$table->json('meta')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['session_id', 'status']);
$table->index(['customer_type', 'customer_id', 'status']);
});
}
// Cart items table
if (!Schema::hasTable(config('shop.tables.cart_items', 'cart_items'))) {
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->integer('quantity')->default(1);
$table->decimal('price', 10, 2);
$table->decimal('regular_price', 10, 2)->nullable();
$table->decimal('subtotal', 10, 2);
$table->json('attributes')->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');
});
}
// Cart discounts table
if (!Schema::hasTable(config('shop.tables.cart_discounts', 'cart_discounts'))) {
Schema::create(config('shop.tables.cart_discounts', 'cart_discounts'), function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('cart_id');
$table->string('code')->nullable();
$table->string('type')->default('percentage'); // percentage, fixed, shipping
$table->decimal('amount', 10, 2);
$table->decimal('discount_amount', 10, 2);
$table->json('meta')->nullable();
$table->timestamps();
$table->index('cart_id');
$table->foreign('cart_id')->references('id')->on(config('shop.tables.carts', 'carts'))->onDelete('cascade');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists(config('shop.tables.cart_discounts', 'cart_discounts'));
Schema::dropIfExists(config('shop.tables.cart_items', 'cart_items'));
Schema::dropIfExists(config('shop.tables.carts', 'carts'));
Schema::dropIfExists(config('shop.tables.product_purchases', 'product_purchases'));
Schema::dropIfExists(config('shop.tables.product_actions', 'product_actions'));
Schema::dropIfExists(config('shop.tables.product_category_product', 'product_category_product'));
Schema::dropIfExists(config('shop.tables.product_relations', 'product_relations'));
Schema::dropIfExists(config('shop.tables.product_stock_logs', 'product_stock_logs'));
Schema::dropIfExists(config('shop.tables.product_stocks', 'product_stocks'));
Schema::dropIfExists(config('shop.tables.product_attributes', 'product_attributes'));
Schema::dropIfExists(config('shop.tables.product_prices', 'product_prices'));
Schema::dropIfExists('product_category_product');
Schema::dropIfExists(config('shop.tables.product_categories', 'product_categories'));
Schema::dropIfExists(config('shop.tables.products', 'products'));
}
};

406
docs/01-products.md Normal file
View File

@ -0,0 +1,406 @@
# Product Management
## Creating Products
### Minimal Product Creation
The absolute minimum to create a product:
```php
use Blax\Shop\Models\Product;
$product = Product::create([
'slug' => 'my-product',
]);
```
This will automatically:
- Generate a random slug if not provided
- Create a default name "New Product [slug]"
- Set status to 'draft'
- Set type to 'simple'
### Basic Product Creation
```php
$product = Product::create([
'slug' => 'blue-hoodie',
'sku' => 'HOOD-BLU-001',
'type' => 'simple',
'price' => 49.99,
'regular_price' => 49.99,
'status' => 'published',
'visible' => true,
'featured' => false,
]);
// Add translated content
$product->setLocalized('name', 'Blue Hoodie', 'en');
$product->setLocalized('description', 'Comfortable cotton hoodie', 'en');
$product->setLocalized('short_description', 'Cotton hoodie', 'en');
```
### Advanced Product Creation
```php
$product = Product::create([
// Basic Info
'slug' => 'premium-headphones',
'sku' => 'HEAD-PREM-001',
'type' => 'simple',
'status' => 'published',
'visible' => true,
'featured' => true,
'published_at' => now(),
'sort_order' => 10,
// Pricing
'price' => 199.99,
'regular_price' => 249.99,
'sale_price' => 199.99,
'sale_start' => now(),
'sale_end' => now()->addDays(7),
// Stock Management
'manage_stock' => true,
'stock_quantity' => 50,
'low_stock_threshold' => 10,
'in_stock' => true,
'stock_status' => 'instock',
// Physical Properties
'weight' => 0.5, // kg
'length' => 20, // cm
'width' => 15, // cm
'height' => 10, // cm
'virtual' => false,
'downloadable' => false,
// Tax
'tax_class' => 'standard',
// Custom Meta
'meta' => [
'brand' => 'AudioPro',
'color' => 'black',
'warranty' => '2 years',
],
]);
// Add translations
$product->setLocalized('name', 'Premium Wireless Headphones', 'en');
$product->setLocalized('name', 'Auriculares Premium Inalámbricos', 'es');
$product->setLocalized('description', 'High-quality wireless headphones with noise cancellation', 'en');
$product->setLocalized('short_description', 'Premium wireless headphones', 'en');
```
## Product Types
### Simple Product
```php
$product = Product::create([
'type' => 'simple',
'slug' => 't-shirt',
'price' => 19.99,
]);
```
### Variable Product (Parent)
```php
$parent = Product::create([
'type' => 'variable',
'slug' => 'hoodie',
'price' => 49.99, // Base price
]);
// Create variants
$small = Product::create([
'type' => 'simple',
'slug' => 'hoodie-small',
'sku' => 'HOOD-S',
'parent_id' => $parent->id,
'price' => 49.99,
]);
$medium = Product::create([
'type' => 'simple',
'slug' => 'hoodie-medium',
'sku' => 'HOOD-M',
'parent_id' => $parent->id,
'price' => 49.99,
]);
$large = Product::create([
'type' => 'simple',
'slug' => 'hoodie-large',
'sku' => 'HOOD-L',
'parent_id' => $parent->id,
'price' => 54.99, // Different price
]);
```
### Grouped Product
```php
$bundle = Product::create([
'type' => 'grouped',
'slug' => 'starter-bundle',
'price' => 99.99,
]);
// Link products to the bundle (handle this in your app logic)
```
### Virtual/Downloadable Product
```php
$ebook = Product::create([
'slug' => 'laravel-guide',
'price' => 29.99,
'virtual' => true,
'downloadable' => true,
'manage_stock' => false, // Virtual products don't need stock
]);
```
## Product Attributes
Add custom attributes to products:
```php
use Blax\Shop\Models\ProductAttribute;
// Add size attribute
ProductAttribute::create([
'product_id' => $product->id,
'key' => 'size',
'value' => 'Large',
'type' => 'select',
'sort_order' => 1,
]);
// Add color attribute
ProductAttribute::create([
'product_id' => $product->id,
'key' => 'color',
'value' => '#FF0000',
'type' => 'color',
'sort_order' => 2,
]);
// Retrieve attributes
$attributes = $product->attributes;
```
## Product Categories
```php
use Blax\Shop\Models\ProductCategory;
// Create category
$category = ProductCategory::create([
'slug' => 'clothing',
]);
$category->setLocalized('name', 'Clothing', 'en');
// Attach product to category
$product->categories()->attach($category->id);
// Detach
$product->categories()->detach($category->id);
// Sync categories
$product->categories()->sync([
$category1->id,
$category2->id,
]);
```
## Multi-Currency Pricing
```php
use Blax\Shop\Models\ProductPrice;
// Add EUR pricing
ProductPrice::create([
'product_id' => $product->id,
'currency' => 'EUR',
'price' => 39.99,
'is_default' => false,
]);
// Add GBP pricing
ProductPrice::create([
'product_id' => $product->id,
'currency' => 'GBP',
'price' => 34.99,
'is_default' => false,
]);
// Get all prices
$prices = $product->prices;
// Get price for specific currency
$eurPrice = $product->prices()->where('currency', 'EUR')->first();
```
## Product Relations
### Related Products
```php
// Attach related products
$product->relatedProducts()->attach($relatedProduct->id, [
'type' => 'related',
'sort_order' => 1,
]);
// Get all related products
$related = $product->relatedProducts()->get();
```
### Upsells
```php
// Attach upsell product
$product->relatedProducts()->attach($premiumProduct->id, [
'type' => 'upsell',
'sort_order' => 1,
]);
// Get upsells
$upsells = $product->upsells;
```
### Cross-sells
```php
// Attach cross-sell product
$product->relatedProducts()->attach($accessory->id, [
'type' => 'cross-sell',
'sort_order' => 1,
]);
// Get cross-sells
$crossSells = $product->crossSells;
```
## Querying Products
### Basic Queries
```php
// Published products
$products = Product::published()->get();
// In stock products
$products = Product::inStock()->get();
// Featured products
$products = Product::featured()->get();
// Visible products (published and within publish date)
$products = Product::visible()->get();
```
### Advanced Queries
```php
// Search products
$products = Product::search('hoodie')->get();
// Filter by category
$products = Product::byCategory($categoryId)->get();
// Price range
$products = Product::priceRange(10, 50)->get();
// Order by price
$products = Product::orderByPrice('asc')->get();
// Low stock products
$products = Product::lowStock()->get();
// Combined query
$products = Product::visible()
->inStock()
->byCategory($categoryId)
->priceRange(20, 100)
->orderByPrice('asc')
->paginate(20);
```
## Product Methods
### Sale Detection
```php
if ($product->isOnSale()) {
echo "On sale!";
}
```
### Current Price
```php
$price = $product->getCurrentPrice(); // Returns sale_price if on sale, otherwise regular_price
```
### Visibility Check
```php
if ($product->isVisible()) {
// Show product
}
```
### Low Stock Check
```php
if ($product->isLowStock()) {
// Show low stock warning
}
```
## API Serialization
```php
// Get API-friendly array
$data = $product->toApiArray();
// Returns:
// [
// 'id' => '...',
// 'slug' => '...',
// 'name' => '...',
// 'price' => 49.99,
// 'is_on_sale' => true,
// 'in_stock' => true,
// 'categories' => [...],
// 'attributes' => [...],
// 'variants' => [...],
// // ...
// ]
```
## Events
The package dispatches events on product lifecycle:
```php
use Blax\Shop\Events\ProductCreated;
use Blax\Shop\Events\ProductUpdated;
// Listen to events in your EventServiceProvider
protected $listen = [
ProductCreated::class => [
SendProductCreatedNotification::class,
],
ProductUpdated::class => [
ClearProductCache::class,
],
];
```

355
docs/02-stripe.md Normal file
View File

@ -0,0 +1,355 @@
# Stripe Integration
## Configuration
### Enable Stripe
Add to your `.env`:
```env
SHOP_STRIPE_ENABLED=true
SHOP_STRIPE_SYNC_PRICES=true
STRIPE_KEY=your_stripe_key
STRIPE_SECRET=your_stripe_secret
```
Update `config/shop.php`:
```php
'stripe' => [
'enabled' => env('SHOP_STRIPE_ENABLED', false),
'sync_prices' => env('SHOP_STRIPE_SYNC_PRICES', true),
'api_version' => '2023-10-16',
],
```
## Creating Products in Stripe
### Manual Stripe Product Creation
```php
use App\Services\StripeService;
$product = Product::create([
'slug' => 'premium-plan',
'price' => 29.99,
'status' => 'published',
]);
// Create in Stripe
$stripeProduct = StripeService::createProduct($product);
// Store Stripe product ID
$product->update([
'stripe_product_id' => $stripeProduct->id,
]);
```
### Automatic Sync
If you have event listeners set up, products can be automatically synced to Stripe:
```php
use Blax\Shop\Events\ProductCreated;
use App\Listeners\SyncProductToStripe;
// In EventServiceProvider
protected $listen = [
ProductCreated::class => [
SyncProductToStripe::class,
],
];
// Listener implementation
class SyncProductToStripe
{
public function handle(ProductCreated $event)
{
if (config('shop.stripe.enabled')) {
$stripeProduct = StripeService::createProduct($event->product);
$event->product->update([
'stripe_product_id' => $stripeProduct->id,
]);
}
}
}
```
## Syncing Prices to Stripe
### Create Stripe Prices
```php
use App\Services\StripeService;
use Blax\Shop\Models\ProductPrice;
// Sync default price
StripeService::syncProductPricesDown($product);
// Create additional currency prices
$eurPrice = ProductPrice::create([
'product_id' => $product->id,
'currency' => 'EUR',
'price' => 24.99,
]);
// Create corresponding Stripe price
$stripePrice = StripeService::createPrice($product, $eurPrice);
$eurPrice->update([
'stripe_price_id' => $stripePrice->id,
]);
```
## Creating Checkout Sessions
### One-time Payment
```php
use Stripe\Stripe;
use Stripe\Checkout\Session;
Stripe::setApiKey(config('services.stripe.secret'));
$product = Product::find($productId);
$session = Session::create([
'payment_method_types' => ['card'],
'line_items' => [[
'price_data' => [
'currency' => 'usd',
'product_data' => [
'name' => $product->getLocalized('name'),
'description' => $product->getLocalized('short_description'),
],
'unit_amount' => $product->getCurrentPrice() * 100, // Convert to cents
],
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('checkout.cancel'),
'metadata' => [
'product_id' => $product->id,
],
]);
return redirect($session->url);
```
### Using Stripe Price IDs
```php
// If you have synced prices
$priceId = $product->prices()
->where('currency', 'USD')
->where('is_default', true)
->first()
->stripe_price_id;
$session = Session::create([
'payment_method_types' => ['card'],
'line_items' => [[
'price' => $priceId,
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('checkout.cancel'),
]);
```
## Handling Webhooks
### Register Webhook Endpoint
```php
// routes/api.php
use App\Http\Controllers\StripeWebhookController;
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle']);
```
### Webhook Controller
```php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Stripe\Webhook;
use Blax\Shop\Models\Product;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
$webhookSecret = config('services.stripe.webhook_secret');
try {
$event = Webhook::constructEvent($payload, $sigHeader, $webhookSecret);
} catch (\Exception $e) {
return response()->json(['error' => 'Invalid signature'], 400);
}
switch ($event->type) {
case 'checkout.session.completed':
$this->handleCheckoutCompleted($event->data->object);
break;
case 'payment_intent.succeeded':
$this->handlePaymentSucceeded($event->data->object);
break;
case 'charge.refunded':
$this->handleRefund($event->data->object);
break;
}
return response()->json(['status' => 'success']);
}
protected function handleCheckoutCompleted($session)
{
$productId = $session->metadata->product_id ?? null;
if (!$productId) {
return;
}
$product = Product::find($productId);
if (!$product) {
return;
}
// Decrease stock
$quantity = $session->metadata->quantity ?? 1;
$product->decreaseStock($quantity);
// Create purchase record
$purchase = $product->purchases()->create([
'purchasable_type' => get_class(auth()->user()),
'purchasable_id' => $session->customer ?? $session->client_reference_id,
'quantity' => $quantity,
'status' => 'completed',
'meta' => [
'stripe_session_id' => $session->id,
'stripe_payment_intent' => $session->payment_intent,
],
]);
// Trigger product actions
$product->callActions('purchased', $purchase, [
'stripe_session' => $session,
]);
}
protected function handlePaymentSucceeded($paymentIntent)
{
// Handle successful payment
}
protected function handleRefund($charge)
{
// Handle refund
$metadata = $charge->metadata;
$productId = $metadata->product_id ?? null;
if ($productId) {
$product = Product::find($productId);
$quantity = $metadata->quantity ?? 1;
$product->increaseStock($quantity);
// Trigger refund actions
$product->callActions('refunded', null, [
'stripe_charge' => $charge,
]);
}
}
}
```
### Configure Webhook Secret
Add to `.env`:
```env
STRIPE_WEBHOOK_SECRET=whsec_...
```
Get your webhook secret from Stripe Dashboard → Developers → Webhooks.
## Multi-Currency Support
### Create Prices for Multiple Currencies
```php
$product = Product::create([
'slug' => 'premium-plan',
'price' => 29.99, // USD base price
]);
// USD (default)
ProductPrice::create([
'product_id' => $product->id,
'currency' => 'USD',
'price' => 29.99,
'is_default' => true,
]);
// EUR
ProductPrice::create([
'product_id' => $product->id,
'currency' => 'EUR',
'price' => 24.99,
]);
// GBP
ProductPrice::create([
'product_id' => $product->id,
'currency' => 'GBP',
'price' => 21.99,
]);
// Sync all to Stripe
StripeService::syncProductPricesDown($product);
```
### Checkout with Currency Selection
```php
$currency = $request->input('currency', 'USD');
$price = $product->prices()
->where('currency', $currency)
->first();
$session = Session::create([
'payment_method_types' => ['card'],
'line_items' => [[
'price' => $price->stripe_price_id,
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => route('checkout.success'),
'cancel_url' => route('checkout.cancel'),
]);
```
## Testing
### Use Stripe Test Mode
```env
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
```
### Test Card Numbers

582
docs/03-purchasing.md Normal file
View File

@ -0,0 +1,582 @@
# Purchasing Products
## Setup
First, add the `HasShoppingCapabilities` trait to your User model (or any model that should purchase products):
```php
use Blax\Shop\Traits\HasShoppingCapabilities;
class User extends Authenticatable
{
use HasShoppingCapabilities;
}
```
## Direct Purchase
### Simple Purchase
```php
$user = auth()->user();
$product = Product::find($productId);
try {
$purchase = $user->purchase($product, quantity: 1);
// Purchase successful
return response()->json([
'success' => true,
'purchase_id' => $purchase->id,
]);
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
```
### Purchase with Options
```php
$purchase = $user->purchase($product, quantity: 2, options: [
'price_id' => $priceId, // Use specific price
'charge_id' => $paymentId, // Associate with payment
'cart_id' => $cartId, // Associate with cart
'status' => 'pending', // Custom status
]);
```
### Check Purchase History
```php
// Check if user has purchased a product
if ($user->hasPurchased($product)) {
// User has purchased this product
}
// Get purchase history for a product
$history = $user->getPurchaseHistory($product);
// Get all completed purchases
$purchases = $user->completedPurchases()->get();
```
## Shopping Cart
### Add to Cart
```php
$user = auth()->user();
$product = Product::find($productId);
try {
$cartItem = $user->addToCart($product, quantity: 1);
return response()->json([
'success' => true,
'cart_item' => $cartItem,
'cart_total' => $user->getCartTotal(),
'cart_count' => $user->getCartItemsCount(),
]);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 400);
}
```
### Update Cart Quantity
```php
$cartItem = ProductPurchase::find($cartItemId);
try {
$user->updateCartQuantity($cartItem, quantity: 3);
return response()->json([
'success' => true,
'cart_total' => $user->getCartTotal(),
]);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 400);
}
```
### Remove from Cart
```php
$cartItem = ProductPurchase::find($cartItemId);
$user->removeFromCart($cartItem);
```
### Get Cart Information
```php
// Get all cart items
$cartItems = $user->cartItems()->with('product')->get();
// Get cart total
$total = $user->getCartTotal();
// Get items count
$count = $user->getCartItemsCount();
// Clear cart
$user->clearCart();
```
### Checkout
```php
try {
$completedPurchases = $user->checkout(options: [
'charge_id' => $paymentIntent->id,
]);
return response()->json([
'success' => true,
'purchases' => $completedPurchases,
'total' => $completedPurchases->sum('amount'),
]);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 400);
}
```
## Refunds
```php
$purchase = ProductPurchase::find($purchaseId);
$user = $purchase->purchasable;
try {
$user->refundPurchase($purchase, options: [
'refund_id' => $refundId,
'reason' => 'Customer request',
]);
return response()->json(['success' => true]);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 400);
}
```
## Purchase Statistics
```php
$stats = $user->getPurchaseStats();
// Returns:
// [
// 'total_purchases' => 10,
// 'total_spent' => 299.90,
// 'total_items' => 15,
// 'cart_items' => 2,
// 'cart_total' => 49.98,
// ]
```
## Basic Purchase Flow
### 1. Check Product Availability
```php
use Blax\Shop\Models\Product;
$product = Product::find($productId);
$quantity = 1;
// Check if product is available
if (!$product->isVisible()) {
return response()->json(['error' => 'Product not available'], 404);
}
// Check stock
if ($product->manage_stock) {
$available = $product->getAvailableStock();
if ($available < $quantity) {
return response()->json([
'error' => 'Insufficient stock',
'available' => $available
], 400);
}
}
```
### 2. Reserve Stock (Optional)
Reserve stock during checkout process:
```php
// Reserve for 15 minutes
$reservation = $product->reserveStock(
quantity: $quantity,
reference: auth()->user(),
until: now()->addMinutes(15),
note: 'Checkout reservation'
);
if (!$reservation) {
return response()->json(['error' => 'Unable to reserve stock'], 400);
}
// Store reservation ID in session
session(['stock_reservation_id' => $reservation->id]);
```
### 3. Process Payment
```php
// Your payment processing logic
$payment = PaymentService::process([
'amount' => $product->getCurrentPrice() * $quantity,
'currency' => 'USD',
'product_id' => $product->id,
]);
if ($payment->failed()) {
// Release reservation
$reservation->update(['status' => 'cancelled']);
return response()->json(['error' => 'Payment failed'], 400);
}
```
### 4. Complete Purchase
```php
use Blax\Shop\Models\ProductPurchase;
// Decrease stock
$product->decreaseStock($quantity);
// Create purchase record
$purchase = ProductPurchase::create([
'product_id' => $product->id,
'purchasable_type' => get_class(auth()->user()),
'purchasable_id' => auth()->id(),
'quantity' => $quantity,
'status' => 'completed',
'meta' => [
'payment_id' => $payment->id,
'price_paid' => $product->getCurrentPrice(),
'currency' => 'USD',
],
]);
// Complete reservation
if ($reservation) {
$reservation->update(['status' => 'completed']);
}
// Trigger product actions
$product->callActions('purchased', $purchase, [
'user' => auth()->user(),
'payment' => $payment,
]);
return response()->json([
'success' => true,
'purchase_id' => $purchase->id,
]);
```
## Shopping Cart Implementation
### Cart Item Model
```php
// app/Models/CartItem.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Blax\Shop\Models\Product;
class CartItem extends Model
{
protected $fillable = [
'cart_id',
'product_id',
'quantity',
'price',
];
protected $casts = [
'price' => 'decimal:2',
];
public function product()
{
return $this->belongsTo(Product::class);
}
public function getSubtotal()
{
return $this->price * $this->quantity;
}
}
```
### Cart Service
```php
// app/Services/CartService.php
namespace App\Services;
use App\Models\CartItem;
use Blax\Shop\Models\Product;
class CartService
{
public function add(Product $product, int $quantity = 1)
{
$cart = $this->getCart();
// Check stock
if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
throw new \Exception('Insufficient stock');
}
// Check if item already in cart
$cartItem = $cart->items()->where('product_id', $product->id)->first();
if ($cartItem) {
$newQuantity = $cartItem->quantity + $quantity;
// Check stock for new quantity
if ($product->manage_stock && $product->getAvailableStock() < $newQuantity) {
throw new \Exception('Insufficient stock for requested quantity');
}
$cartItem->update(['quantity' => $newQuantity]);
} else {
$cartItem = $cart->items()->create([
'product_id' => $product->id,
'quantity' => $quantity,
'price' => $product->getCurrentPrice(),
]);
}
return $cartItem;
}
public function update(CartItem $cartItem, int $quantity)
{
$product = $cartItem->product;
// Check stock
if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
throw new \Exception('Insufficient stock');
}
$cartItem->update(['quantity' => $quantity]);
return $cartItem;
}
public function remove(CartItem $cartItem)
{
$cartItem->delete();
}
public function clear()
{
$cart = $this->getCart();
$cart->items()->delete();
}
public function getTotal()
{
$cart = $this->getCart();
return $cart->items->sum(fn($item) => $item->getSubtotal());
}
public function checkout()
{
$cart = $this->getCart();
$items = $cart->items()->with('product')->get();
// Reserve stock for all items
$reservations = [];
foreach ($items as $item) {
$reservation = $item->product->reserveStock(
$item->quantity,
$cart,
now()->addMinutes(15)
);
if (!$reservation) {
// Rollback previous reservations
foreach ($reservations as $res) {
$res->update(['status' => 'cancelled']);
}
throw new \Exception('Unable to reserve stock for: ' . $item->product->getLocalized('name'));
}
$reservations[] = $reservation;
}
return [
'items' => $items,
'reservations' => $reservations,
'total' => $this->getTotal(),
];
}
protected function getCart()
{
// Implementation depends on your cart system
// Could be session-based or user-based
return auth()->user()->cart ?? session()->get('cart');
}
}
```
### Cart Controller
```php
// app/Http/Controllers/CartController.php
namespace App\Http\Controllers;
use App\Services\CartService;
use Blax\Shop\Models\Product;
use Illuminate\Http\Request;
class CartController extends Controller
{
public function __construct(
protected CartService $cartService
) {}
public function add(Request $request, Product $product)
{
$validated = $request->validate([
'quantity' => 'required|integer|min:1',
]);
try {
$cartItem = $this->cartService->add($product, $validated['quantity']);
return response()->json([
'success' => true,
'cart_item' => $cartItem,
'cart_total' => $this->cartService->getTotal(),
]);
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
public function update(Request $request, $cartItemId)
{
$validated = $request->validate([
'quantity' => 'required|integer|min:1',
]);
$cartItem = CartItem::findOrFail($cartItemId);
try {
$this->cartService->update($cartItem, $validated['quantity']);
return response()->json([
'success' => true,
'cart_total' => $this->cartService->getTotal(),
]);
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
public function remove($cartItemId)
{
$cartItem = CartItem::findOrFail($cartItemId);
$this->cartService->remove($cartItem);
return response()->json([
'success' => true,
'cart_total' => $this->cartService->getTotal(),
]);
}
public function checkout()
{
try {
$checkoutData = $this->cartService->checkout();
return response()->json([
'success' => true,
'checkout' => $checkoutData,
]);
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
}
```
## Handling Refunds
```php
public function refund($purchaseId)
{
$purchase = ProductPurchase::findOrFail($purchaseId);
$product = $purchase->product;
// Process refund with payment processor
$refund = PaymentService::refund($purchase->meta['payment_id']);
if ($refund->success) {
// Return stock
$product->increaseStock($purchase->quantity);
// Update purchase status
$purchase->update([
'status' => 'refunded',
'meta' => array_merge($purchase->meta, [
'refund_id' => $refund->id,
'refunded_at' => now(),
]),
]);
// Trigger refund actions
$product->callActions('refunded', $purchase, [
'refund' => $refund,
]);
return response()->json(['success' => true]);
}
return response()->json(['error' => 'Refund failed'], 400);
}
```
## Product Actions on Purchase
Product actions allow you to execute custom logic when products are purchased:
```php
use Blax\Shop\Models\ProductAction;
// Create action to grant access to a course
ProductAction::create([
'product_id' => $product->id,
'action_type' => 'grant_access',
'event' => 'purchased',
'config' => [
'resource_type' => 'course',
'resource_id' => 123,
],
'active' => true,
]);
// Action is automatically triggered when product is purchased
// Implement the action handler in your application
```
See [Product Actions documentation](docs/07-product-actions.md) for more details.

489
docs/04-subscriptions.md Normal file
View File

@ -0,0 +1,489 @@
# Subscriptions
## Creating Subscription Products
### Basic Subscription Product
```php
use Blax\Shop\Models\Product;
$subscription = Product::create([
'slug' => 'monthly-premium',
'sku' => 'SUB-PREM-M',
'type' => 'simple',
'price' => 29.99,
'virtual' => true,
'downloadable' => false,
'manage_stock' => false, // Subscriptions don't need stock management
'status' => 'published',
'meta' => [
'billing_period' => 'month',
'billing_interval' => 1,
'trial_days' => 7,
],
]);
$subscription->setLocalized('name', 'Premium Monthly Subscription', 'en');
$subscription->setLocalized('description', 'Access to all premium features', 'en');
```
### Subscription Tiers
```php
// Basic
$basic = Product::create([
'slug' => 'basic-monthly',
'price' => 9.99,
'virtual' => true,
'meta' => [
'billing_period' => 'month',
'features' => ['feature_1', 'feature_2'],
],
]);
// Pro
$pro = Product::create([
'slug' => 'pro-monthly',
'price' => 29.99,
'virtual' => true,
'meta' => [
'billing_period' => 'month',
'features' => ['feature_1', 'feature_2', 'feature_3', 'feature_4'],
],
]);
// Enterprise
$enterprise = Product::create([
'slug' => 'enterprise-monthly',
'price' => 99.99,
'virtual' => true,
'meta' => [
'billing_period' => 'month',
'features' => ['all_features', 'priority_support', 'custom_branding'],
],
]);
```
## Stripe Subscription Integration
### Create Subscription Prices in Stripe
```php
use Stripe\Stripe;
use Stripe\Product as StripeProduct;
use Stripe\Price;
Stripe::setApiKey(config('services.stripe.secret'));
// Create Stripe product
$stripeProduct = StripeProduct::create([
'name' => $subscription->getLocalized('name'),
'description' => $subscription->getLocalized('description'),
'metadata' => [
'product_id' => $subscription->id,
],
]);
// Create recurring price
$price = Price::create([
'product' => $stripeProduct->id,
'unit_amount' => $subscription->price * 100,
'currency' => 'usd',
'recurring' => [
'interval' => 'month',
'interval_count' => 1,
],
]);
// Save Stripe IDs
$subscription->update([
'stripe_product_id' => $stripeProduct->id,
]);
ProductPrice::create([
'product_id' => $subscription->id,
'currency' => 'USD',
'price' => $subscription->price,
'stripe_price_id' => $price->id,
'is_default' => true,
]);
```
### Create Subscription Checkout
```php
use Stripe\Checkout\Session;
$subscription = Product::find($subscriptionId);
$priceId = $subscription->prices()
->where('currency', 'USD')
->first()
->stripe_price_id;
$session = Session::create([
'payment_method_types' => ['card'],
'line_items' => [[
'price' => $priceId,
'quantity' => 1,
]],
'mode' => 'subscription',
'success_url' => route('subscription.success') . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('subscription.cancel'),
'client_reference_id' => auth()->id(),
'customer_email' => auth()->user()->email,
'subscription_data' => [
'trial_period_days' => $subscription->meta['trial_days'] ?? null,
'metadata' => [
'product_id' => $subscription->id,
'user_id' => auth()->id(),
],
],
]);
return redirect($session->url);
```
## Handling Subscription Webhooks
### Webhook Controller
```php
namespace App\Http\Controllers;
use Stripe\Webhook;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPurchase;
class StripeSubscriptionWebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
$webhookSecret = config('services.stripe.webhook_secret');
try {
$event = Webhook::constructEvent($payload, $sigHeader, $webhookSecret);
} catch (\Exception $e) {
return response()->json(['error' => 'Invalid signature'], 400);
}
switch ($event->type) {
case 'customer.subscription.created':
$this->handleSubscriptionCreated($event->data->object);
break;
case 'customer.subscription.updated':
$this->handleSubscriptionUpdated($event->data->object);
break;
case 'customer.subscription.deleted':
$this->handleSubscriptionCancelled($event->data->object);
break;
case 'invoice.payment_succeeded':
$this->handlePaymentSucceeded($event->data->object);
break;
case 'invoice.payment_failed':
$this->handlePaymentFailed($event->data->object);
break;
}
return response()->json(['status' => 'success']);
}
protected function handleSubscriptionCreated($subscription)
{
$productId = $subscription->metadata->product_id ?? null;
$userId = $subscription->metadata->user_id ?? null;
if (!$productId || !$userId) {
return;
}
$product = Product::find($productId);
$user = User::find($userId);
// Create purchase record
$purchase = ProductPurchase::create([
'product_id' => $product->id,
'purchasable_type' => get_class($user),
'purchasable_id' => $user->id,
'quantity' => 1,
'status' => $subscription->status,
'meta' => [
'stripe_subscription_id' => $subscription->id,
'stripe_customer_id' => $subscription->customer,
'current_period_end' => $subscription->current_period_end,
'trial_end' => $subscription->trial_end,
],
]);
// Trigger subscription started actions
$product->callActions('subscription_started', $purchase, [
'subscription' => $subscription,
'user' => $user,
]);
// Grant access
$user->subscriptions()->create([
'product_id' => $product->id,
'stripe_subscription_id' => $subscription->id,
'status' => 'active',
'trial_ends_at' => $subscription->trial_end ?
Carbon::createFromTimestamp($subscription->trial_end) : null,
'ends_at' => Carbon::createFromTimestamp($subscription->current_period_end),
]);
}
protected function handleSubscriptionUpdated($subscription)
{
$purchase = ProductPurchase::where('meta->stripe_subscription_id', $subscription->id)->first();
if ($purchase) {
$purchase->update([
'status' => $subscription->status,
'meta' => array_merge($purchase->meta, [
'current_period_end' => $subscription->current_period_end,
]),
]);
// Update user subscription
$userSubscription = $purchase->purchasable->subscriptions()
->where('stripe_subscription_id', $subscription->id)
->first();
if ($userSubscription) {
$userSubscription->update([
'status' => $subscription->status === 'active' ? 'active' : 'inactive',
'ends_at' => Carbon::createFromTimestamp($subscription->current_period_end),
]);
}
}
}
protected function handleSubscriptionCancelled($subscription)
{
$purchase = ProductPurchase::where('meta->stripe_subscription_id', $subscription->id)->first();
if ($purchase) {
$purchase->update([
'status' => 'cancelled',
]);
// Revoke access
$userSubscription = $purchase->purchasable->subscriptions()
->where('stripe_subscription_id', $subscription->id)
->first();
if ($userSubscription) {
$userSubscription->update([
'status' => 'cancelled',
'ends_at' => now(),
]);
}
// Trigger cancellation actions
$purchase->product->callActions('subscription_cancelled', $purchase, [
'subscription' => $subscription,
]);
}
}
protected function handlePaymentSucceeded($invoice)
{
$subscriptionId = $invoice->subscription;
$purchase = ProductPurchase::where('meta->stripe_subscription_id', $subscriptionId)->first();
if ($purchase) {
// Trigger renewal actions
$purchase->product->callActions('subscription_renewed', $purchase, [
'invoice' => $invoice,
]);
}
}
protected function handlePaymentFailed($invoice)
{
$subscriptionId = $invoice->subscription;
$purchase = ProductPurchase::where('meta->stripe_subscription_id', $subscriptionId)->first();
if ($purchase) {
// Trigger payment failed actions
$purchase->product->callActions('subscription_payment_failed', $purchase, [
'invoice' => $invoice,
]);
}
}
}
```
## User Subscription Model
```php
// app/Models/UserSubscription.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Blax\Shop\Models\Product;
class UserSubscription extends Model
{
protected $fillable = [
'user_id',
'product_id',
'stripe_subscription_id',
'status',
'trial_ends_at',
'ends_at',
];
protected $casts = [
'trial_ends_at' => 'datetime',
'ends_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
public function isActive()
{
return $this->status === 'active' &&
(!$this->ends_at || $this->ends_at->isFuture());
}
public function onTrial()
{
return $this->trial_ends_at && $this->trial_ends_at->isFuture();
}
public function cancel()
{
if (!$this->stripe_subscription_id) {
return false;
}
try {
$stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
$stripe->subscriptions->cancel($this->stripe_subscription_id);
$this->update([
'status' => 'cancelled',
'ends_at' => now(),
]);
return true;
} catch (\Exception $e) {
return false;
}
}
}
```
## Checking Subscription Access
```php
// Add to User model
public function subscriptions()
{
return $this->hasMany(UserSubscription::class);
}
public function hasActiveSubscription($productSlug = null)
{
$query = $this->subscriptions()->where('status', 'active');
if ($productSlug) {
$query->whereHas('product', function ($q) use ($productSlug) {
$q->where('slug', $productSlug);
});
}
return $query->where(function ($q) {
$q->whereNull('ends_at')
->orWhere('ends_at', '>', now());
})
->exists();
}
// Usage in controllers/middleware
if (!auth()->user()->hasActiveSubscription('premium-monthly')) {
abort(403, 'Active subscription required');
}
```
## Subscription Management Routes
```php
// routes/web.php
Route::middleware('auth')->group(function () {
Route::get('/subscriptions', [SubscriptionController::class, 'index']);
Route::post('/subscriptions/{product}/subscribe', [SubscriptionController::class, 'subscribe']);
Route::post('/subscriptions/{subscription}/cancel', [SubscriptionController::class, 'cancel']);
Route::post('/subscriptions/{subscription}/resume', [SubscriptionController::class, 'resume']);
});
```
## Product Actions for Subscriptions
```php
use Blax\Shop\Models\ProductAction;
// Grant role on subscription
ProductAction::create([
'product_id' => $subscription->id,
'action_type' => 'grant_role',
'event' => 'subscription_started',
'config' => [
'role' => 'premium_member',
],
'active' => true,
]);
// Revoke role on cancellation
ProductAction::create([
'product_id' => $subscription->id,
'action_type' => 'revoke_role',
'event' => 'subscription_cancelled',
'config' => [
'role' => 'premium_member',
],
'active' => true,
]);
```
## Annual Subscriptions with Discount
```php
$annual = Product::create([
'slug' => 'premium-annual',
'price' => 299.99, // Save $60 vs monthly
'regular_price' => 359.88,
'sale_price' => 299.99,
'virtual' => true,
'meta' => [
'billing_period' => 'year',
'billing_interval' => 1,
'savings' => 59.89,
],
]);
// Create Stripe price
$price = Price::create([
'product' => $stripeProduct->id,
'unit_amount' => 29999,
'currency' => 'usd',
'recurring' => [
'interval' => 'year',
'interval_count' => 1,
],
]);
```

31
phpunit.xml Normal file
View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
processIsolation="false"
stopOnFailure="false"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="BlaxShop Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<coverage includeUncoveredFiles="true">
<include>
<directory suffix=".php">./src</directory>
</include>
<exclude>
<directory>./src/database</directory>
</exclude>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
</php>
</phpunit>

4
pint.json Normal file
View File

@ -0,0 +1,4 @@
{
"preset": "laravel",
"rules": {}
}

33
routes/api.php Normal file
View File

@ -0,0 +1,33 @@
<?php
use Blax\Shop\Http\Controllers\Api\CategoryController;
use Blax\Shop\Http\Controllers\Api\ProductController;
use Illuminate\Support\Facades\Route;
$config = config('shop.routes');
Route::prefix($config['prefix'])
->middleware($config['middleware'])
->name($config['name_prefix'])
->group(function () {
// Categories
Route::get('categories', [CategoryController::class, 'index'])
->name('categories.index');
Route::get('categories/tree', [CategoryController::class, 'tree'])
->name('categories.tree');
Route::get('categories/{slug}', [CategoryController::class, 'show'])
->name('categories.show');
Route::get('categories/{slug}/products', [CategoryController::class, 'products'])
->name('categories.products');
// Products
Route::get('products', [ProductController::class, 'index'])
->name('products.index');
Route::get('products/{slug}', [ProductController::class, 'show'])
->name('products.show');
});

27
shell.nix Normal file
View File

@ -0,0 +1,27 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
php82
php82Packages.composer
php82Extensions.dom
php82Extensions.mbstring
php82Extensions.xml
php82Extensions.xmlwriter
php82Extensions.tokenizer
php82Extensions.pdo
php82Extensions.pdo_sqlite
php82Extensions.sqlite3
php82Extensions.json
php82Extensions.libxml
php82Extensions.curl
php82Extensions.openssl
];
shellHook = ''
echo "Laravel Package Test Environment"
echo "PHP version: $(php --version | head -n 1)"
echo ""
echo "Run tests with: composer test"
'';
}

View File

@ -0,0 +1,29 @@
<?php
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\ProductStock;
use Illuminate\Console\Command;
class ReleaseExpiredStocks extends Command
{
protected $signature = 'shop:release-expired-stocks';
protected $description = 'Release expired stock reservations back to inventory';
public function handle(): int
{
if (!config('shop.stock.auto_release_expired', true)) {
$this->info('Auto-release is disabled in config.');
return self::SUCCESS;
}
$this->info('Checking for expired stock reservations...');
$count = ProductStock::releaseExpired();
$this->info("Released {$count} expired stock reservation(s).");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\ProductAction;
use Illuminate\Console\Command;
class ShopAvailableActionsCommand extends Command
{
protected $signature = 'shop:available-actions';
protected $description = 'List all available action classes that can be used';
public function handle()
{
$actions = ProductAction::getAvailableActions();
if (empty($actions)) {
$this->warn('No action classes found.');
$this->info('Make sure auto_discover is enabled in config/shop.php');
$this->info('Path: ' . config('shop.actions.path', app_path('Jobs/ProductAction')));
return 0;
}
$this->info('Available Action Classes:');
$this->newLine();
foreach ($actions as $className => $parameters) {
$this->line("• <fg=green>{$className}</>");
if (!empty($parameters)) {
$this->line(' Parameters:');
foreach ($parameters as $param => $description) {
$this->line(" - {$param}: {$description}");
}
} else {
$this->line(' No parameters');
}
$this->newLine();
}
$this->info("Total: " . count($actions) . " action class(es)");
return 0;
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\ProductAction;
use Illuminate\Console\Command;
class ShopListActionsCommand extends Command
{
protected $signature = 'shop:list-actions
{product? : Product ID to filter by}
{--event= : Filter by event type}
{--enabled : Only show enabled actions}
{--disabled : Only show disabled actions}';
protected $description = 'List all product actions';
public function handle()
{
$query = ProductAction::with('product');
if ($productId = $this->argument('product')) {
$query->where('product_id', $productId);
}
if ($event = $this->option('event')) {
$query->where('event', $event);
}
if ($this->option('enabled')) {
$query->where('enabled', true);
} elseif ($this->option('disabled')) {
$query->where('enabled', false);
}
$actions = $query->orderBy('product_id')->orderBy('priority')->get();
if ($actions->isEmpty()) {
$this->info('No actions found.');
return 0;
}
$headers = ['ID', 'Product', 'Event', 'Action Class', 'Priority', 'Enabled', 'Parameters'];
$rows = $actions->map(function ($action) {
return [
$action->id,
$action->product->name ?? "ID: {$action->product_id}",
$action->event,
$action->action_class,
$action->priority,
$action->enabled ? '✓' : '✗',
json_encode($action->parameters),
];
});
$this->table($headers, $rows);
$this->info("Total actions: {$actions->count()}");
return 0;
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
class ShopListProductsCommand extends Command
{
protected $signature = 'shop:list-products
{--with-actions : Include action counts}
{--with-purchases : Include purchase counts}
{--enabled : Only show enabled products}
{--disabled : Only show disabled products}';
protected $description = 'List all products in the shop';
public function handle()
{
$productModel = config('shop.models.product');
$query = $productModel::query();
if ($this->option('enabled')) {
$query->where('enabled', true);
} elseif ($this->option('disabled')) {
$query->where('enabled', false);
}
if ($this->option('with-actions')) {
$query->withCount('actions');
}
if ($this->option('with-purchases')) {
$query->withCount('purchases');
}
$products = $query->orderBy('id')->get();
if ($products->isEmpty()) {
$this->info('No products found.');
return 0;
}
$headers = ['ID', 'Name', 'Price', 'Type', 'Enabled'];
if ($this->option('with-actions')) {
$headers[] = 'Actions';
}
if ($this->option('with-purchases')) {
$headers[] = 'Purchases';
}
$rows = $products->map(function ($product) {
$row = [
$product->id,
$product->name,
$product->price,
$product->type ?? 'N/A',
$product->enabled ? '✓' : '✗',
];
if ($this->option('with-actions')) {
$row[] = $product->actions_count ?? 0;
}
if ($this->option('with-purchases')) {
$row[] = $product->purchases_count ?? 0;
}
return $row;
});
$this->table($headers, $rows);
$this->info("Total products: {$products->count()}");
return 0;
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
class ShopListPurchasesCommand extends Command
{
protected $signature = 'shop:list-purchases
{product? : Product ID to filter by}
{--user= : Filter by user ID}
{--status= : Filter by status}
{--limit=50 : Number of purchases to show}';
protected $description = 'List product purchases';
public function handle()
{
$purchaseModel = config('shop.models.product_purchase');
$query = $purchaseModel::with(['product', 'user']);
if ($productId = $this->argument('product')) {
$query->where('product_id', $productId);
}
if ($userId = $this->option('user')) {
$query->where('user_id', $userId);
}
if ($status = $this->option('status')) {
$query->where('status', $status);
}
$limit = (int) $this->option('limit');
$purchases = $query->latest()->limit($limit)->get();
if ($purchases->isEmpty()) {
$this->info('No purchases found.');
return 0;
}
$headers = ['ID', 'Product', 'User', 'Price', 'Status', 'Date'];
$rows = $purchases->map(function ($purchase) {
return [
$purchase->id,
$purchase->product->name ?? "ID: {$purchase->product_id}",
$purchase->user->name ?? "ID: {$purchase->user_id}",
$purchase->price,
$purchase->status ?? 'N/A',
$purchase->created_at->format('Y-m-d H:i:s'),
];
});
$this->table($headers, $rows);
$this->info("Showing {$purchases->count()} purchase(s)");
return 0;
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class ShopReinstallCommand extends Command
{
protected $signature = 'shop:reinstall
{--force : Force the operation without confirmation}
{--fresh : Drop tables without confirmation}';
protected $description = 'Drop and recreate all shop tables (USE WITH CAUTION)';
protected array $shopTables = [
'product_relations',
'product_stock_logs',
'product_actions',
'product_stocks',
'product_attributes',
'product_prices',
'product_category_product',
'product_categories',
'product_purchases',
'order_items',
'orders',
'cart_items',
'carts',
];
public function handle()
{
if (!$this->option('force') && !$this->option('fresh')) {
$this->error('⚠️ WARNING: This will DELETE ALL shop data!');
if (!$this->confirm('Are you absolutely sure you want to continue?')) {
$this->info('Operation cancelled.');
return 0;
}
if (!$this->confirm('This action cannot be undone. Continue?')) {
$this->info('Operation cancelled.');
return 0;
}
}
$this->info('Starting shop reinstallation...');
// Disable foreign key checks
Schema::disableForeignKeyConstraints();
$this->dropShopTables();
$this->runMigrations();
// Re-enable foreign key checks
Schema::enableForeignKeyConstraints();
$this->info('✅ Shop tables reinstalled successfully!');
return 0;
}
protected function dropShopTables(): void
{
$this->info('Dropping shop tables...');
// Add products table from config
$productsTable = config('shop.tables.products', 'products');
$allTables = array_merge([$productsTable], $this->shopTables);
foreach ($allTables as $table) {
if (Schema::hasTable($table)) {
Schema::dropIfExists($table);
$this->line(" - Dropped: {$table}");
}
}
// Remove migration records
$this->removeMigrationRecords();
}
protected function removeMigrationRecords(): void
{
$this->info('Cleaning migration records...');
DB::table('migrations')
->where('migration', 'like', '%shop%')
->orWhere('migration', 'like', '%product%')
->orWhere('migration', 'like', '%cart%')
->orWhere('migration', 'like', '%order%')
->delete();
}
protected function runMigrations(): void
{
$this->info('Running shop migrations...');
$this->call('migrate', [
'--path' => 'database/migrations/create_blax_shop_tables.php.stub',
'--force' => true,
]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\ProductAction;
use Illuminate\Console\Command;
class ShopStatsCommand extends Command
{
protected $signature = 'shop:stats';
protected $description = 'Display shop statistics';
public function handle()
{
$productModel = config('shop.models.product');
$purchaseModel = config('shop.models.product_purchase');
$totalProducts = $productModel::count();
$enabledProducts = $productModel::where('enabled', true)->count();
$disabledProducts = $productModel::where('enabled', false)->count();
$totalActions = ProductAction::count();
$enabledActions = ProductAction::where('enabled', true)->count();
$disabledActions = ProductAction::where('enabled', false)->count();
$totalPurchases = $purchaseModel::count();
$totalRevenue = $purchaseModel::sum('price');
$this->info('=== Shop Statistics ===');
$this->newLine();
$this->table(
['Metric', 'Count'],
[
['Total Products', $totalProducts],
['Enabled Products', $enabledProducts],
['Disabled Products', $disabledProducts],
['---', '---'],
['Total Actions', $totalActions],
['Enabled Actions', $enabledActions],
['Disabled Actions', $disabledActions],
['---', '---'],
['Total Purchases', $totalPurchases],
['Total Revenue', number_format($totalRevenue, 2)],
]
);
return 0;
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\ProductAction;
use Illuminate\Console\Command;
class ShopTestActionCommand extends Command
{
protected $signature = 'shop:test-action
{action-id : The ID of the action to test}
{--sync : Execute synchronously instead of queuing}';
protected $description = 'Test execute a product action';
public function handle()
{
$actionId = $this->argument('action-id');
$action = ProductAction::with('product')->find($actionId);
if (!$action) {
$this->error("Action with ID {$actionId} not found.");
return 1;
}
$this->info("Testing action: {$action->action_class}");
$this->info("Product: {$action->product->name} (ID: {$action->product_id})");
$this->info("Event: {$action->event}");
if (!$this->confirm('Do you want to proceed?')) {
$this->info('Test cancelled.');
return 0;
}
try {
if ($this->option('sync')) {
$namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction');
$action_job = $namespace . '\\' . $action->action_class;
$params = [
'product' => $action->product,
'productPurchase' => null,
'event' => $action->event,
...($action->parameters ?? []),
];
(new $action_job(...$params))->handle();
$this->info('Action executed synchronously.');
} else {
$action->execute($action->product, null, []);
$this->info('Action dispatched to queue.');
}
$this->info('✓ Action test completed successfully.');
return 0;
} catch (\Exception $e) {
$this->error('✗ Action test failed: ' . $e->getMessage());
$this->error($e->getTraceAsString());
return 1;
}
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\ProductAction;
use Illuminate\Console\Command;
class ShopToggleActionCommand extends Command
{
protected $signature = 'shop:toggle-action
{action-id : The ID of the action to toggle}
{--enable : Enable the action}
{--disable : Disable the action}';
protected $description = 'Enable or disable a product action';
public function handle()
{
$actionId = $this->argument('action-id');
$action = ProductAction::find($actionId);
if (!$action) {
$this->error("Action with ID {$actionId} not found.");
return 1;
}
if ($this->option('enable')) {
$action->enabled = true;
$status = 'enabled';
} elseif ($this->option('disable')) {
$action->enabled = false;
$status = 'disabled';
} else {
$action->enabled = !$action->enabled;
$status = $action->enabled ? 'enabled' : 'disabled';
}
$action->save();
$this->info("Action #{$action->id} ({$action->action_class}) has been {$status}.");
return 0;
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Blax\Shop\Contracts;
interface Purchasable
{
public function getCurrentPrice(): ?float;
public function isOnSale(): bool;
public function decreaseStock(int $quantity = 1): bool;
public function increaseStock(int $quantity = 1): void;
}

View File

@ -0,0 +1,14 @@
<?php
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ProductCreated
{
use Dispatchable, SerializesModels;
public function __construct(public Product $product) {}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ProductUpdated
{
use Dispatchable, SerializesModels;
public function __construct(public Product $product) {}
}

View File

@ -0,0 +1,64 @@
<?php
namespace Blax\Shop\Http\Controllers\Api;
use Blax\Shop\Models\ProductCategory;
use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller;
class CategoryController extends Controller
{
public function index(): JsonResponse
{
$categories = ProductCategory::visible()
->roots()
->with('children')
->orderBy('sort_order')
->get();
return response()->json([
'data' => $categories,
]);
}
public function tree(): JsonResponse
{
return response()->json([
'data' => ProductCategory::getTree(),
]);
}
public function show(string $slug): JsonResponse
{
$category = ProductCategory::visible()
->where('slug', $slug)
->with(['children', 'parent'])
->firstOrFail();
return response()->json([
'data' => array_merge(
$category->toArray(),
['breadcrumbs' => $category->getPath()]
),
]);
}
public function products(string $slug): JsonResponse
{
$category = ProductCategory::visible()
->where('slug', $slug)
->firstOrFail();
$perPage = min(
request('per_page', config('shop.pagination.per_page')),
config('shop.pagination.max_per_page')
);
$products = $category->products()
->published()
->inStock()
->paginate($perPage);
return response()->json($products);
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace Blax\Shop\Http\Controllers\Api;
use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller;
class ProductController extends Controller
{
public function index(): JsonResponse
{
$productModel = config('shop.models.product');
$perPage = min(
request('per_page', config('shop.pagination.per_page')),
config('shop.pagination.max_per_page')
);
$query = $productModel::query()
->published()
->visible();
if (request('category')) {
$query->whereHas('categories', function ($q) {
$q->where('slug', request('category'));
});
}
if (request('featured')) {
$query->featured();
}
if (request('in_stock')) {
$query->inStock();
}
$products = $query->with(['categories'])
->paginate($perPage);
return response()->json($products);
}
public function show(string $slug): JsonResponse
{
$productModel = config('shop.models.product');
$product = $productModel::query()
->published()
->visible()
->where('slug', $slug)
->with(['categories', 'images', 'children', 'attributes'])
->firstOrFail();
return response()->json([
'data' => array_merge(
$product->toArray(),
[
'current_price' => $product->getCurrentPrice(),
'on_sale' => $product->isOnSale(),
'average_rating' => $product->getAverageRating(),
]
),
]);
}
}

112
src/Models/Cart.php Normal file
View File

@ -0,0 +1,112 @@
<?php
namespace Blax\Shop\Models;
use Blax\Workkit\Traits\HasExpiration;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Cart extends Model
{
use HasUuids, HasExpiration;
protected $fillable = [
'session_id',
'customer_type',
'customer_id',
'currency',
'status',
'last_activity_at',
'expires_at',
'converted_at',
'meta',
];
protected $casts = [
'expires_at' => 'datetime',
'converted_at' => 'datetime',
'last_activity_at' => 'datetime',
'meta' => 'object',
];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->table = config('shop.tables.carts', 'carts');
}
protected static function boot()
{
parent::boot();
// No longer need to generate uuid - using id as primary key
}
public function customer(): MorphTo
{
return $this->morphTo();
}
// Alias for backward compatibility
public function user()
{
return $this->customer();
}
public function items(): HasMany
{
return $this->hasMany(config('shop.models.cart_item'), 'cart_id');
}
public function purchases(): HasMany
{
return $this->hasMany(config('shop.models.product_purchase', \Blax\Shop\Models\ProductPurchase::class), 'cart_id');
}
public function getTotal(): float
{
return $this->items->sum(function ($item) {
return $item->quantity * $item->price;
});
}
public function getTotalItems(): int
{
return $this->items->sum('quantity');
}
public function isExpired(): bool
{
return $this->expires_at && $this->expires_at->isPast();
}
public function isConverted(): bool
{
return !is_null($this->converted_at);
}
public function scopeActive($query)
{
return $query->whereNull('converted_at')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
public function scopeForUser($query, $userOrId)
{
if (is_object($userOrId)) {
return $query->where('customer_id', $userOrId->id)
->where('customer_type', get_class($userOrId));
}
// If just an ID is passed, try to determine the user model class
$userModel = config('auth.providers.users.model', \Workbench\App\Models\User::class);
return $query->where('customer_id', $userOrId)
->where('customer_type', $userModel);
}
}

81
src/Models/CartItem.php Normal file
View File

@ -0,0 +1,81 @@
<?php
namespace Blax\Shop\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CartItem extends Model
{
use HasUuids;
protected $fillable = [
'cart_id',
'product_id',
'quantity',
'price',
'regular_price',
'subtotal',
'attributes',
'meta',
];
protected $casts = [
'quantity' => 'integer',
'price' => 'decimal:2',
'regular_price' => 'decimal:2',
'subtotal' => 'decimal:2',
'attributes' => 'array',
'meta' => 'array',
];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->table = config('shop.tables.cart_items', 'cart_items');
}
protected static function boot()
{
parent::boot();
// Auto-calculate subtotal before saving
static::creating(function ($cartItem) {
if (!isset($cartItem->subtotal)) {
$cartItem->subtotal = $cartItem->quantity * $cartItem->price;
}
});
static::updating(function ($cartItem) {
if ($cartItem->isDirty(['quantity', 'price'])) {
$cartItem->subtotal = $cartItem->quantity * $cartItem->price;
}
});
}
public function cart(): BelongsTo
{
return $this->belongsTo(config('shop.models.cart'), 'cart_id');
}
public function product(): BelongsTo
{
return $this->belongsTo(config('shop.models.product'), 'product_id');
}
public function getSubtotal(): float
{
return $this->quantity * $this->price;
}
public function scopeForCart($query, $cartId)
{
return $query->where('cart_id', $cartId);
}
public function scopeForProduct($query, $productId)
{
return $query->where('product_id', $productId);
}
}

487
src/Models/Product.php Normal file
View File

@ -0,0 +1,487 @@
<?php
namespace Blax\Shop\Models;
use App\Services\StripeService;
use Blax\Workkit\Traits\HasMetaTranslation;
use Blax\Shop\Events\ProductCreated;
use Blax\Shop\Events\ProductUpdated;
use Blax\Shop\Contracts\Purchasable;
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\Support\Facades\Cache;
class Product extends Model implements Purchasable
{
use HasFactory, HasUuids, HasMetaTranslation;
protected $fillable = [
'slug',
'type',
'stripe_product_id',
'price',
'regular_price',
'sale_price',
'sale_start',
'sale_end',
'manage_stock',
'stock_quantity',
'low_stock_threshold',
'in_stock',
'stock_status',
'weight',
'length',
'width',
'height',
'virtual',
'downloadable',
'parent_id',
'featured',
'visible',
'status',
'published_at',
'meta',
'sku',
'tax_class',
'sort_order',
];
protected $casts = [
'manage_stock' => 'boolean',
'in_stock' => 'boolean',
'virtual' => 'boolean',
'downloadable' => 'boolean',
'meta' => 'object',
'sale_start' => 'datetime',
'sale_end' => 'datetime',
'published_at' => 'datetime',
'featured' => 'boolean',
'visible' => 'boolean',
'low_stock_threshold' => 'integer',
'sort_order' => 'integer',
];
// Remove - causes issues with casting
protected $dispatchesEvents = [
'created' => ProductCreated::class,
'updated' => ProductUpdated::class,
];
protected $hidden = [
'stripe_product_id',
];
public function __construct(array $attributes = [])
{
// Initialize meta BEFORE parent constructor to avoid trait errors
if (!isset($attributes['meta'])) {
$attributes['meta'] = '{}';
}
parent::__construct($attributes);
$this->setTable(config('shop.tables.products', 'products'));
}
/**
* Initialize the HasMetaTranslation trait for the model.
*
* @return void
*/
protected function initializeHasMetaTranslation()
{
// Ensure meta is never null
if (!isset($this->attributes['meta'])) {
$this->attributes['meta'] = '{}';
}
}
protected static function booted()
{
parent::booted();
static::creating(function ($model) {
if (! $model->slug) {
$model->slug = 'new-product-' . str()->random(8);
}
$model->slug = str()->slug($model->slug);
// Ensure meta is initialized before creation
if (is_null($model->getAttributes()['meta'] ?? null)) {
$model->setAttribute('meta', json_encode(new \stdClass()));
}
});
static::created(function ($model) {
if (! $model->name) {
// Temporarily disabled to fix meta initialization issue
// TODO: Fix this properly by ensuring meta is always available
// $model->setLocalized('name', 'New Product "' . $model->slug . '"', null, true);
// $model->save();
}
});
static::updated(function ($model) {
if (config('shop.cache.enabled')) {
Cache::forget(config('shop.cache.prefix') . 'product:' . $model->id);
}
});
}
public function prices(): HasMany
{
return $this->hasMany(config('shop.models.product_price', ProductPrice::class));
}
public function parent()
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
public function categories(): BelongsToMany
{
return $this->belongsToMany(
config('shop.models.product_category'),
'product_category_product'
);
}
public function attributes(): HasMany
{
return $this->hasMany(config('shop.models.product_attribute', 'Blax\Shop\Models\ProductAttribute'));
}
public function stocks(): HasMany
{
return $this->hasMany(config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'));
}
public function actions(): HasMany
{
return $this->hasMany(config('shop.models.product_action', ProductAction::class));
}
public function activeStocks(): HasMany
{
return $this->stocks()->pending();
}
public function scopePublished($query)
{
return $query->where('status', 'published');
}
public function scopeInStock($query)
{
return $query->where('in_stock', true)
->where(function ($q) {
$q->where('manage_stock', false)
->orWhere('stock_quantity', '>', 0);
});
}
public function scopeFeatured($query)
{
return $query->where('featured', true);
}
public function isOnSale(): bool
{
if (!$this->sale_price) {
return false;
}
$now = now();
if ($this->sale_start && $now->lt($this->sale_start)) {
return false;
}
if ($this->sale_end && $now->gt($this->sale_end)) {
return false;
}
return true;
}
public function getCurrentPrice(): ?float
{
if ($this->isOnSale()) {
return $this->sale_price;
}
$defaultPrice = $this->defaultPrice()->first();
return $defaultPrice ? $defaultPrice->price : $this->regular_price;
}
public function decreaseStock(int $quantity = 1): bool
{
if (!$this->manage_stock) {
return true;
}
if ($this->stock_quantity < $quantity && !config('shop.stock.allow_backorders')) {
return false;
}
$this->stock_quantity -= $quantity;
$this->in_stock = $this->stock_quantity > 0;
if (config('shop.stock.log_changes', true)) {
$this->logStockChange(-$quantity, 'decrease');
}
$this->save();
return true;
}
public function increaseStock(int $quantity = 1): void
{
if (!$this->manage_stock) {
return;
}
$this->stock_quantity += $quantity;
$this->in_stock = true;
if (config('shop.stock.log_changes', true)) {
$this->logStockChange($quantity, 'increase');
}
$this->save();
}
public function reserveStock(
int $quantity,
$reference = null,
?\DateTimeInterface $until = null,
?string $note = null
): ?\Blax\Shop\Models\ProductStock {
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock');
return $stockModel::reserve(
$this,
$quantity,
'reservation',
$reference,
$until,
$note
);
}
public function getAvailableStock(): int
{
if (!$this->manage_stock) {
return PHP_INT_MAX;
}
return max(0, $this->stock_quantity);
}
public function getReservedStock(): int
{
return $this->activeStocks()->sum('quantity');
}
protected function logStockChange(int $quantityChange, string $type): void
{
\DB::table('product_stock_logs')->insert([
'product_id' => $this->id,
'quantity_change' => $quantityChange,
'quantity_after' => $this->stock_quantity,
'type' => $type,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function syncPricesDown()
{
if (config('shop.stripe.enabled') && config('shop.stripe.sync_prices')) {
StripeService::syncProductPricesDown($this);
}
return $this;
}
public static function getAvailableActions(): array
{
return ProductAction::getAvailableActions();
}
public function callActions(string $event = 'purchased', ?ProductPurchase $productPurchase = null, array $additionalData = []): void
{
ProductAction::callForProduct($this, $event, $productPurchase, $additionalData);
}
public function relatedProducts(): BelongsToMany
{
return $this->belongsToMany(
self::class,
'product_relations',
'product_id',
'related_product_id'
)->withPivot('type')->withTimestamps();
}
public function upsells(): BelongsToMany
{
return $this->relatedProducts()->wherePivot('type', 'upsell');
}
public function crossSells(): BelongsToMany
{
return $this->relatedProducts()->wherePivot('type', 'cross-sell');
}
public function scopeVisible($query)
{
return $query->where('visible', true)
->where('status', 'published')
->where(function ($q) {
$q->whereNull('published_at')
->orWhere('published_at', '<=', now());
});
}
public function scopeByCategory($query, $categoryId)
{
return $query->whereHas('categories', function ($q) use ($categoryId) {
$q->where('id', $categoryId);
});
}
public function scopeSearch($query, string $search)
{
return $query->where(function ($q) use ($search) {
$q->where('slug', 'like', "%{$search}%")
->orWhere('sku', 'like', "%{$search}%")
->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.name')) LIKE ?", ["%{$search}%"]);
});
}
public function scopePriceRange($query, ?float $min = null, ?float $max = null)
{
if ($min !== null) {
$query->where('price', '>=', $min);
}
if ($max !== null) {
$query->where('price', '<=', $max);
}
return $query;
}
public function scopeOrderByPrice($query, string $direction = 'asc')
{
return $query->orderBy('price', $direction);
}
public function scopeLowStock($query)
{
return $query->where('manage_stock', true)
->whereColumn('stock_quantity', '<=', 'low_stock_threshold');
}
public function isLowStock(): bool
{
if (!$this->manage_stock || !$this->low_stock_threshold) {
return false;
}
return $this->stock_quantity <= $this->low_stock_threshold;
}
public function isVisible(): bool
{
if (!$this->visible || $this->status !== 'published') {
return false;
}
if ($this->published_at && now()->lt($this->published_at)) {
return false;
}
return true;
}
public function toApiArray(): array
{
return [
'id' => $this->id,
'slug' => $this->slug,
'sku' => $this->sku,
'name' => $this->getLocalized('name'),
'description' => $this->getLocalized('description'),
'short_description' => $this->getLocalized('short_description'),
'type' => $this->type,
'price' => $this->getCurrentPrice(),
'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,
'downloadable' => $this->downloadable,
'weight' => $this->weight,
'dimensions' => [
'length' => $this->length,
'width' => $this->width,
'height' => $this->height,
],
'categories' => $this->categories,
'attributes' => $this->attributes,
'variants' => $this->children,
'parent' => $this->parent,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
/**
* Get an attribute from the model.
*
* @param string $key
* @return mixed
*/
public function getAttribute($key)
{
$value = parent::getAttribute($key);
// Ensure meta is never null for HasMetaTranslation trait
if ($key === 'meta' && is_null($value)) {
$this->attributes['meta'] = '{}';
return json_decode('{}');
}
return $value;
}
/**
* Create a new instance of the given model.
*
* @param array $attributes
* @param bool $exists
* @return static
*/
public function newInstance($attributes = [], $exists = false)
{
// Ensure meta is initialized
if (!isset($attributes['meta'])) {
$attributes['meta'] = '{}';
}
return parent::newInstance($attributes, $exists);
}
}

View File

@ -0,0 +1,147 @@
<?php
namespace Blax\Shop\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Log;
class ProductAction extends Model
{
use HasUuids;
protected $fillable = [
'product_id',
'event',
'action_type',
'config',
'active',
'sort_order',
];
protected $casts = [
'config' => 'array',
'active' => 'boolean',
'sort_order' => 'integer',
];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->setTable(config('shop.tables.product_actions', 'product_actions'));
}
public function product(): BelongsTo
{
return $this->belongsTo(config('shop.models.product', Product::class));
}
public static function getAvailableActions(): array
{
if (!config('shop.actions.auto_discover')) {
return [];
}
$path = config('shop.actions.path', app_path('Jobs/ProductAction'));
$namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction');
if (!file_exists($path)) {
return [];
}
$actions = collect(glob($path . '/*.php'));
$actions = $actions->mapWithKeys(function ($filePath) use ($path, $namespace) {
$className = str_replace(['.php', $path . '/'], '', $filePath);
$class = $namespace . '\\' . $className;
if (!class_exists($class) || !method_exists($class, 'parameters')) {
return [];
}
$params = $class::parameters();
return [$className => $params];
});
return $actions->toArray();
}
public static function callForProduct(
Product $product,
string $event,
?ProductPurchase $productPurchase = null,
array $additionalData = []
): void {
$actions = $product->actions()
->where('event', $event)
->where('active', true)
->orderBy('sort_order')
->get();
if ($actions->isEmpty()) {
return;
}
$available_actions = self::getAvailableActions();
foreach ($actions as $action) {
try {
if (!isset($available_actions[$action->action_type])) {
Log::warning('Product action not found', [
'product_id' => $product->id,
'event' => $event,
'action_type' => $action->action_type,
]);
continue;
}
$namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction');
$action_job = $namespace . '\\' . $action->action_type;
$params = [
'product' => $product,
'productPurchase' => $productPurchase,
'event' => $event,
...($action->config ?? []),
...$additionalData,
];
dispatch(new $action_job(...$params));
} catch (\Exception $e) {
Log::error('Error calling product action', [
'product_id' => $product->id,
'event' => $event,
'action_type' => $action->action_type ?? 'unknown',
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
report($e);
}
}
}
public function execute(
Product $product,
?ProductPurchase $productPurchase = null,
array $additionalData = []
): void {
$namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction');
$action_job = $namespace . '\\' . $this->action_type;
if (!class_exists($action_job)) {
throw new \Exception("Action class {$action_job} not found");
}
$params = [
'product' => $product,
'productPurchase' => $productPurchase,
'event' => $this->event,
...($this->config ?? []),
...$additionalData,
];
dispatch(new $action_job(...$params));
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace Blax\Shop\Models;
use Blax\Shop\Models\Product;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductAttribute extends Model
{
use HasUuids;
protected $fillable = [
'product_id',
'name',
'value',
'sort_order',
'meta',
];
protected $casts = [
'sort_order' => 'integer',
'meta' => 'object',
];
protected $hidden = [
'id',
'product_id',
'created_at',
'updated_at',
];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->setTable(config('shop.tables.product_attributes', 'product_attributes'));
}
public function product(): BelongsTo
{
return $this->belongsTo(config('shop.models.product', Product::class));
}
}

View File

@ -0,0 +1,168 @@
<?php
namespace Blax\Shop\Models;
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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Cache;
class ProductCategory extends Model
{
use HasFactory, HasUuids;
protected $fillable = [
'name',
'slug',
'description',
'parent_id',
'sort_order',
'visible',
'meta',
];
protected $casts = [
'visible' => 'boolean',
'meta' => 'object',
];
protected $hidden = [
'created_at',
'updated_at',
];
protected $appends = [
'product_count',
];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->setTable(config('shop.tables.product_categories', 'product_categories'));
}
protected static function booted()
{
static::creating(function ($model) {
if (!$model->slug) {
$model->slug = str()->slug($model->name);
}
});
static::saved(function ($model) {
if (config('shop.cache.enabled')) {
Cache::forget(config('shop.cache.prefix') . 'categories:tree');
Cache::forget(config('shop.cache.prefix') . 'category:' . $model->id);
}
});
static::deleted(function ($model) {
if (config('shop.cache.enabled')) {
Cache::forget(config('shop.cache.prefix') . 'categories:tree');
}
});
}
public function products(): BelongsToMany
{
return $this->belongsToMany(
config('shop.models.product'),
'product_category_product'
);
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id')
->where('visible', true)
->orderBy('sort_order');
}
public function allChildren(): HasMany
{
return $this->hasMany(self::class, 'parent_id')
->orderBy('sort_order');
}
public function scopeVisible($query)
{
return $query->where('visible', true);
}
public function scopeRoots($query)
{
return $query->whereNull('parent_id');
}
// Backward compatibility accessor
public function getIsVisibleAttribute(): bool
{
return $this->attributes['visible'] ?? true;
}
public function getProductCountAttribute(): int
{
return $this->products()->count();
}
public function toArray(): array
{
$array = parent::toArray();
// Only include nested children if explicitly loaded
if ($this->relationLoaded('children')) {
$array['children'] = $this->children->toArray();
}
return $array;
}
public static function getTree(): array
{
if (config('shop.cache.enabled')) {
return Cache::remember(
config('shop.cache.prefix') . 'categories:tree',
config('shop.cache.ttl'),
fn() => self::buildTree()
);
}
return self::buildTree();
}
protected static function buildTree(): array
{
$categories = self::visible()
->with('children')
->whereNull('parent_id')
->orderBy('sort_order')
->get();
return $categories->toArray();
}
public function getPath(): array
{
$path = [];
$category = $this;
while ($category) {
array_unshift($path, [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
]);
$category = $category->parent;
}
return $path;
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace Blax\Shop\Models;
use Blax\Workkit\Traits\HasMetaTranslation;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class ProductPrice extends Model
{
use HasUuids, HasMetaTranslation;
protected $fillable = [
'active',
'product_id',
'stripe_price_id',
'name',
'type',
'price',
'sale_price',
'is_default',
'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',
];
public function product()
{
return $this->belongsTo(Product::class);
}
public function scopeIsActive($query)
{
return $query->where('active', true);
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace Blax\Shop\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class ProductPurchase extends Model
{
use HasUuids;
protected $fillable = [
'status',
'purchasable_type',
'purchasable_id',
'product_id',
'quantity',
'meta',
];
protected $casts = [
'quantity' => 'integer',
'meta' => 'object',
];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->setTable(config('shop.tables.product_purchases', 'product_purchases'));
}
public function purchasable()
{
return $this->morphTo();
}
// Backward compatibility - user accessor
public function user()
{
if ($this->purchasable_type === config('auth.providers.users.model', \Workbench\App\Models\User::class)) {
return $this->purchasable();
}
return null;
}
// Backward compatibility accessor
public function getUserIdAttribute()
{
if ($this->purchasable_type === config('auth.providers.users.model', \Workbench\App\Models\User::class)) {
return $this->purchasable_id;
}
return null;
}
public function product()
{
return $this->belongsTo(config('shop.models.product', Product::class));
}
public static function scopeFromCart($query, $cartId)
{
return $query->where('cart_id', $cartId);
}
public static function scopeInCart($query)
{
return $query->where('status', 'cart');
}
public static function scopeCompleted($query)
{
return $query->where('status', 'completed');
}
protected static function booted()
{
static::created(function ($productPurchase) {
if ($productPurchase->status === 'completed' && $product = $productPurchase->product) {
$product->callActions('purchased', $productPurchase);
}
});
}
}

219
src/Models/ProductStock.php Normal file
View File

@ -0,0 +1,219 @@
<?php
namespace Blax\Shop\Models;
use Blax\Shop\Models\Product;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Facades\DB;
class ProductStock extends Model
{
use HasUuids;
protected $fillable = [
'product_id',
'quantity',
'type',
'status',
'reference_type',
'reference_id',
'expires_at',
'note',
'meta',
];
protected $casts = [
'quantity' => 'integer',
'expires_at' => 'datetime',
'meta' => 'object',
];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->setTable(config('shop.tables.product_stocks', 'product_stocks'));
}
protected static function booted()
{
static::created(function ($model) {
$model->logStockChange();
});
static::updated(function ($model) {
if ($model->wasChanged('status') && $model->status === 'completed') {
$model->releaseStock();
}
});
}
public function product(): BelongsTo
{
return $this->belongsTo(config('shop.models.product', Product::class));
}
public function reference(): MorphTo
{
return $this->morphTo();
}
public function scopePending($query)
{
return $query->where('status', 'pending');
}
public function scopeReleased($query)
{
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');
}
public function scopePermanent($query)
{
return $query->whereNull('expires_at');
}
// Backward compatibility accessors
public function getReleasedAtAttribute()
{
return $this->status === 'completed' ? $this->updated_at : null;
}
public function getUntilAtAttribute()
{
return $this->expires_at;
}
public static function reserve(
Product $product,
int $quantity,
?string $type = 'reservation',
$reference = null,
?\DateTimeInterface $until = null,
?string $note = null
): ?self {
return DB::transaction(function () use ($product, $quantity, $type, $reference, $until, $note) {
if (!$product->decreaseStock($quantity)) {
return null;
}
return self::create([
'product_id' => $product->id,
'quantity' => $quantity,
'type' => $type,
'status' => 'pending',
'reference_type' => $reference ? get_class($reference) : null,
'reference_id' => $reference?->id,
'expires_at' => $until,
'note' => $note,
]);
});
}
public function release(): bool
{
if ($this->status !== 'pending') {
return false;
}
return DB::transaction(function () {
$this->product->increaseStock($this->quantity);
$this->status = 'completed';
$this->save();
return true;
});
}
public function isPermanent(): bool
{
return is_null($this->expires_at);
}
public function isTemporary(): bool
{
return !is_null($this->expires_at);
}
public function isExpired(): bool
{
return $this->isTemporary()
&& $this->status === 'pending'
&& $this->expires_at->isPast();
}
public function isActive(): bool
{
return $this->status === 'pending';
}
protected function logStockChange(): void
{
if (!config('shop.stock.log_changes', true)) {
return;
}
DB::table('product_stock_logs')->insert([
'product_id' => $this->product_id,
'quantity_change' => -$this->quantity,
'quantity_after' => $this->product->stock_quantity,
'type' => $this->type,
'note' => $this->note,
'reference_type' => $this->reference_type,
'reference_id' => $this->reference_id,
'created_at' => now(),
'updated_at' => now(),
]);
}
protected function releaseStock(): void
{
if (!config('shop.stock.log_changes', true)) {
return;
}
DB::table('product_stock_logs')->insert([
'product_id' => $this->product_id,
'quantity_change' => $this->quantity,
'quantity_after' => $this->product->stock_quantity,
'type' => 'release',
'note' => 'Stock released from reservation',
'reference_type' => $this->reference_type,
'reference_id' => $this->reference_id,
'created_at' => now(),
'updated_at' => now(),
]);
}
public static function releaseExpired(): int
{
$expired = self::expired()->get();
$count = 0;
foreach ($expired as $stock) {
if ($stock->release()) {
$count++;
}
}
return $count;
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Rapidez\Shop\Services;
use Blax\Shop\Models\Product;
use Illuminate\Support\Collection;
class ShopStripeService
{
public static function syncProductDown(\Stripe\Product $stripeProduct)
{
$product = Product::updateOrCreate(
['stripe_product_id' => $stripeProduct->id],
[
'slug' => str()->slug($stripeProduct->name),
'type' => $stripeProduct->type,
'virtual' => $stripeProduct->type === 'service',
'status' => $stripeProduct->active ? 'published' : 'draft',
]
);
$product->setLocalized('name', $stripeProduct->name);
if (isset($stripeProduct->marketing_features)) {
$product->setLocalized(
'features',
collect($stripeProduct->marketing_features)->map(fn($i) => $i->name)->toArray(),
);
}
$product->save();
// Sync prices
self::syncProductPricesDown($product);
if (app()->runningInConsole()) {
echo "\n";
}
return $product;
}
public static function syncProductPricesDown(Product $product)
{
self::getProductPrices($product->stripe_product_id)->each(function ($stripePrice) use ($product) {
if ($stripePrice->product !== $product->stripe_product_id) {
return;
}
$price = $product->prices()->updateOrCreate(
['stripe_price_id' => $stripePrice->id],
[
'name' => $stripePrice->nickname,
'type' => $stripePrice->type,
'price' => $stripePrice->unit_amount,
'currency' => $stripePrice->currency,
'billing_scheme' => $stripePrice->billing_scheme,
'interval' => $stripePrice->recurring ? $stripePrice->recurring->interval : null,
'interval_count' => $stripePrice->recurring ? $stripePrice->recurring->interval_count : null,
'trial_period_days' => $stripePrice->recurring ? $stripePrice->recurring->trial_period_days : null,
'is_default' => false,
]
);
if (app()->runningInConsole()) {
echo " - Synced price {$price->id} ({$stripePrice->id})\n";
}
});
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace Blax\Shop;
use Blax\Shop\Console\Commands\ShopReinstallCommand;
use Illuminate\Support\ServiceProvider;
class ShopServiceProvider extends ServiceProvider
{
public function register()
{
$this->mergeConfigFrom(
__DIR__ . '/../config/shop.php',
'shop'
);
}
public function boot()
{
// Publish config
$this->publishes([
__DIR__ . '/../config/shop.php' => config_path('shop.php'),
], 'shop-config');
// Publish migrations
$this->publishes([
__DIR__ . '/../database/migrations' => database_path('migrations'),
], 'shop-migrations');
// Load migrations
if ($this->app->runningInConsole()) {
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
}
// Load routes if enabled (API only)
if (config('shop.routes.enabled', true)) {
$this->loadRoutesFrom(__DIR__ . '/../routes/api.php');
}
// Register commands
if ($this->app->runningInConsole()) {
$this->commands([
ShopReinstallCommand::class,
\Blax\Shop\Console\Commands\ReleaseExpiredStocks::class,
\Blax\Shop\Console\Commands\ShopListProductsCommand::class,
\Blax\Shop\Console\Commands\ShopListActionsCommand::class,
\Blax\Shop\Console\Commands\ShopToggleActionCommand::class,
\Blax\Shop\Console\Commands\ShopTestActionCommand::class,
\Blax\Shop\Console\Commands\ShopListPurchasesCommand::class,
\Blax\Shop\Console\Commands\ShopAvailableActionsCommand::class,
\Blax\Shop\Console\Commands\ShopStatsCommand::class,
]);
}
}
}

View File

@ -0,0 +1,403 @@
<?php
namespace Blax\Shop\Traits;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\Product;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Collection;
trait HasShoppingCapabilities
{
/**
* Get all purchases made by this entity
*/
public function purchases(): MorphMany
{
return $this->morphMany(
config('shop.models.product_purchase', ProductPurchase::class),
'purchasable'
);
}
/**
* Get cart items (purchases with status 'cart')
*/
public function cartItems(): MorphMany
{
return $this->purchases()->where('status', 'cart');
}
/**
* Get completed purchases
*/
public function completedPurchases(): MorphMany
{
return $this->purchases()->where('status', 'completed');
}
/**
* Purchase a product
*
* @param Product $product
* @param int $quantity
* @param array $options Additional options (price_id, meta, etc.)
* @return ProductPurchase
* @throws \Exception
*/
public function purchase(Product $product, int $quantity = 1, array $options = []): ProductPurchase
{
// Validate stock availability
if ($product->manage_stock) {
$available = $product->getAvailableStock();
if ($available < $quantity) {
throw new \Exception("Insufficient stock. Available: {$available}, Requested: {$quantity}");
}
}
// Check if product is visible
if (!$product->isVisible()) {
throw new \Exception("Product is not available for purchase");
}
// Decrease stock
if (!$product->decreaseStock($quantity)) {
throw new \Exception("Unable to decrease stock");
}
// Determine price
$priceId = $options['price_id'] ?? null;
$price = $this->determinePurchasePrice($product, $priceId);
// Create purchase record
$purchase = $this->purchases()->create([
'product_id' => $product->id,
'quantity' => $quantity,
'status' => $options['status'] ?? 'completed',
'meta' => array_merge([
'price_id' => $priceId,
'price' => $price,
'amount' => $price * $quantity,
'charge_id' => $options['charge_id'] ?? null,
], $options['meta'] ?? []),
]);
// Trigger product actions
$product->callActions('purchased', $purchase, [
'purchaser' => $this,
...$options,
]);
return $purchase;
}
/**
* Add product to cart
*
* @param Product $product
* @param int $quantity
* @param array $options
* @return ProductPurchase
* @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'] ?? []),
]);
}
/**
* Update cart item quantity
*
* @param ProductPurchase $cartItem
* @param int $quantity
* @return ProductPurchase
* @throws \Exception
*/
public function updateCartQuantity(ProductPurchase $cartItem, int $quantity): ProductPurchase
{
if ($cartItem->status !== 'cart') {
throw new \Exception("Cannot update non-cart item");
}
$product = $cartItem->product;
// Validate stock
if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
throw new \Exception("Insufficient stock available");
}
$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();
}
/**
* Remove item from cart
*
* @param ProductPurchase $cartItem
* @return bool
* @throws \Exception
*/
public function removeFromCart(ProductPurchase $cartItem): bool
{
if ($cartItem->status !== 'cart') {
throw new \Exception("Cannot remove non-cart item");
}
return $cartItem->delete();
}
/**
* Clear all cart items
*
* @param string|null $cartId (deprecated - not used)
* @return int Number of items removed
*/
public function clearCart(?string $cartId = null): int
{
return $this->cartItems()->delete();
}
/**
* Get cart total
*
* @param string|null $cartId (deprecated - not used)
* @return float
*/
public function getCartTotal(?string $cartId = null): float
{
return $this->cartItems()->get()->sum(function ($item) {
$meta = (array) $item->meta;
return $meta['amount'] ?? 0;
});
}
/**
* Get cart items count
*
* @param string|null $cartId (deprecated - not used)
* @return int
*/
public function getCartItemsCount(?string $cartId = null): int
{
return $this->cartItems()->sum('quantity') ?? 0;
}
/**
* Checkout cart - convert cart items to completed purchases
*
* @param string|null $cartId (deprecated - not used)
* @param array $options
* @return Collection
* @throws \Exception
*/
public function checkout(?string $cartId = null, array $options = []): Collection
{
$items = $this->cartItems()->with('product')->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')}");
}
}
// 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;
}
/**
* Check if entity has purchased a product
*
* @param Product|int $product
* @return bool
*/
public function hasPurchased($product): bool
{
$productId = $product instanceof Product ? $product->id : $product;
return $this->completedPurchases()
->where('product_id', $productId)
->exists();
}
/**
* Get purchase history for a product
*
* @param Product|int $product
* @return Collection
*/
public function getPurchaseHistory($product): Collection
{
$productId = $product instanceof Product ? $product->id : $product;
return $this->purchases()
->where('product_id', $productId)
->orderBy('created_at', 'desc')
->get();
}
/**
* Refund a purchase
*
* @param ProductPurchase $purchase
* @param array $options
* @return bool
* @throws \Exception
*/
public function refundPurchase(ProductPurchase $purchase, array $options = []): bool
{
if ($purchase->status !== 'completed') {
throw new \Exception("Can only refund completed purchases");
}
$product = $purchase->product;
// Return stock
$product->increaseStock($purchase->quantity);
// Update purchase
$purchase->update([
'status' => 'refunded',
]);
// Trigger refund actions
$product->callActions('refunded', $purchase, [
'purchaser' => $this,
...$options,
]);
return true;
}
/**
* Get total spent
*
* @return float
*/
public function getTotalSpent(): float
{
return $this->completedPurchases()->sum('amount') ?? 0;
}
/**
* Get purchase statistics
*
* @return array
*/
public function getPurchaseStats(): array
{
return [
'total_purchases' => $this->completedPurchases()->count(),
'total_spent' => $this->getTotalSpent(),
'total_items' => $this->completedPurchases()->sum('quantity'),
'cart_items' => $this->getCartItemsCount(),
'cart_total' => $this->getCartTotal(),
];
}
/**
* Determine purchase price for a product
*
* @param Product $product
* @param string|null $priceId
* @return float
*/
protected function determinePurchasePrice(Product $product, ?string $priceId = null): float
{
if ($priceId) {
$productPrice = $product->prices()->find($priceId);
if ($productPrice) {
return $productPrice->price;
}
}
return $product->getCurrentPrice();
}
/**
* Get or generate current cart ID
*
* @return string
*/
protected function getCurrentCartId(): string
{
// Override this method if you need custom cart ID logic
return 'cart_' . $this->getKey();
}
}

10
test.sh Executable file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env nix-shell
#!nix-shell -i bash -p php82 php82Extensions.dom php82Extensions.mbstring php82Extensions.xml php82Extensions.xmlwriter php82Extensions.tokenizer php82Extensions.pdo php82Extensions.pdo_sqlite php82Extensions.sqlite3 php82Extensions.curl php82Extensions.openssl php82Extensions.fileinfo
# Test script for NixOS - runs PHPUnit with proper PHP extensions
echo "Running Laravel Package Tests..."
echo "PHP version: $(php --version | head -n 1)"
echo ""
vendor/bin/phpunit "$@"

33
testbench.yaml Normal file
View File

@ -0,0 +1,33 @@
laravel: '@testbench'
providers:
# - Workbench\App\Providers\WorkbenchServiceProvider
migrations:
- workbench/database/migrations
seeders:
- Workbench\Database\Seeders\DatabaseSeeder
workbench:
start: '/'
install: true
health: false
discovers:
web: true
api: true
commands: true
components: false
factories: true
views: false
build:
- asset-publish
- create-sqlite-db
- db-wipe
- migrate-fresh
assets:
- laravel-assets
sync:
- from: storage
to: workbench/storage
reverse: true

View File

@ -0,0 +1,339 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
class CartManagementTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_can_create_a_cart()
{
$user = User::factory()->create();
$cart = Cart::create([
'customer_type' => get_class($user),
'customer_id' => $user->id,
'expires_at' => now()->addDays(7),
]);
$this->assertDatabaseHas('carts', [
'id' => $cart->id,
'customer_type' => get_class($user),
'customer_id' => $user->id,
]);
$this->assertNotNull($cart->id);
}
/** @test */
public function it_automatically_generates_uuid()
{
$cart = Cart::create();
$this->assertNotNull($cart->id);
$this->assertIsString($cart->id);
}
/** @test */
public function it_can_add_items_to_cart()
{
$cart = Cart::create();
$product = Product::factory()->create(['price' => 99.99]);
$cartItem = CartItem::create([
'cart_id' => $cart->id,
'product_id' => $product->id,
'quantity' => 2,
'price' => $product->price,
'subtotal' => $product->price * 2,
]);
$this->assertCount(1, $cart->fresh()->items);
$this->assertEquals(2, $cart->items->first()->quantity);
}
/** @test */
public function it_can_update_cart_item_quantity()
{
$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,
]);
$cartItem->update(['quantity' => 3]);
$this->assertEquals(3, $cartItem->fresh()->quantity);
}
/** @test */
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,
]);
$this->assertCount(1, $cart->fresh()->items);
$cartItem->delete();
$this->assertCount(0, $cart->fresh()->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]);
CartItem::create([
'cart_id' => $cart->id,
'product_id' => $product1->id,
'quantity' => 2,
'price' => $product1->price,
'subtotal' => $product1->price * 2,
]);
CartItem::create([
'cart_id' => $cart->id,
'product_id' => $product2->id,
'quantity' => 1,
'price' => $product2->price,
'subtotal' => $product2->price,
]);
$total = $cart->fresh()->getTotal();
$this->assertEquals(130.00, $total); // (50 * 2) + (30 * 1)
}
/** @test */
public function it_calculates_total_items_correctly()
{
$cart = Cart::create();
$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,
]);
CartItem::create([
'cart_id' => $cart->id,
'product_id' => $product2->id,
'quantity' => 2,
'price' => 20.00,
'subtotal' => 40.00,
]);
$totalItems = $cart->fresh()->getTotalItems();
$this->assertEquals(5, $totalItems); // 3 + 2
}
/** @test */
public function it_can_check_if_cart_is_expired()
{
$expiredCart = Cart::create([
'expires_at' => now()->subDay(),
]);
$activeCart = Cart::create([
'expires_at' => now()->addDay(),
]);
$this->assertTrue($expiredCart->isExpired());
$this->assertFalse($activeCart->isExpired());
}
/** @test */
public function it_can_check_if_cart_is_converted()
{
$convertedCart = Cart::create([
'converted_at' => now(),
]);
$activeCart = Cart::create([
'converted_at' => null,
]);
$this->assertTrue($convertedCart->isConverted());
$this->assertFalse($activeCart->isConverted());
}
/** @test */
public function it_can_scope_active_carts()
{
Cart::create([
'expires_at' => now()->addDay(),
'converted_at' => null,
]);
Cart::create([
'expires_at' => now()->subDay(),
'converted_at' => null,
]);
Cart::create([
'expires_at' => now()->addDay(),
'converted_at' => now(),
]);
$activeCarts = Cart::active()->get();
$this->assertCount(1, $activeCarts);
}
/** @test */
public function it_can_scope_carts_for_user()
{
$user = User::factory()->create();
$otherUser = User::factory()->create();
Cart::create(['customer_type' => get_class($user), 'customer_id' => $user->id]);
Cart::create(['customer_type' => get_class($user), 'customer_id' => $user->id]);
Cart::create(['customer_type' => get_class($otherUser), 'customer_id' => $otherUser->id]);
$userCarts = Cart::forUser($user)->get();
$this->assertCount(2, $userCarts);
}
/** @test */
public function it_belongs_to_a_user()
{
$user = User::factory()->create();
$cart = Cart::create(['customer_type' => get_class($user), 'customer_id' => $user->id]);
$this->assertEquals($user->id, $cart->user->id);
}
/** @test */
public function cart_items_have_correct_relationships()
{
$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,
]);
$this->assertEquals($cart->id, $cartItem->cart->id);
$this->assertEquals($product->id, $cartItem->product->id);
}
/** @test */
public function it_calculates_cart_item_subtotal()
{
$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,
]);
$this->assertEquals(100.00, $cartItem->getSubtotal()); // 25 * 4
}
/** @test */
public function it_can_store_cart_item_attributes()
{
$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',
],
]);
$this->assertEquals('blue', $cartItem->attributes['color']);
$this->assertEquals('large', $cartItem->attributes['size']);
}
/** @test */
public function it_can_have_multiple_items_of_same_product_with_different_attributes()
{
$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'],
]);
CartItem::create([
'cart_id' => $cart->id,
'product_id' => $product->id,
'quantity' => 2,
'price' => $product->price,
'subtotal' => $product->price * 2,
'attributes' => ['size' => 'large'],
]);
$this->assertCount(2, $cart->fresh()->items);
}
/** @test */
public function it_deletes_cart_items_when_cart_is_deleted()
{
$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,
]);
$cartItemId = $cartItem->id;
$cart->delete();
$this->assertDatabaseMissing('cart_items', ['id' => $cartItemId]);
}
}

View File

@ -0,0 +1,342 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductAction;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
class ProductActionTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_can_create_a_product_action()
{
$product = Product::factory()->create();
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\SendWelcomeEmail',
'active' => true,
'sort_order' => 10,
]);
$this->assertDatabaseHas('product_actions', [
'id' => $action->id,
'product_id' => $product->id,
'event' => 'purchased',
]);
}
/** @test */
public function product_has_many_actions()
{
$product = Product::factory()->create();
ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\SendWelcomeEmail',
'active' => true,
]);
ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\GrantAccess',
'active' => true,
]);
$this->assertCount(2, $product->fresh()->actions);
}
/** @test */
public function action_belongs_to_product()
{
$product = Product::factory()->create();
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\TestAction',
'active' => true,
]);
$this->assertEquals($product->id, $action->product->id);
}
/** @test */
public function it_can_enable_and_disable_actions()
{
$product = Product::factory()->create();
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\TestAction',
'active' => true,
]);
$this->assertTrue($action->active);
$action->update(['active' => false]);
$this->assertFalse($action->fresh()->active);
}
/** @test */
public function it_can_store_action_parameters()
{
$product = Product::factory()->create();
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\SendEmail',
'parameters' => [
'template' => 'welcome',
'delay' => 60,
'subject' => 'Welcome to our service',
],
'active' => true,
]);
$this->assertEquals('welcome', $action->parameters['template']);
$this->assertEquals(60, $action->parameters['delay']);
$this->assertEquals('Welcome to our service', $action->parameters['subject']);
}
/** @test */
public function it_can_set_action_priority()
{
$product = Product::factory()->create();
$action1 = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\FirstAction',
'sort_order' => 1,
'active' => true,
]);
$action2 = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\SecondAction',
'sort_order' => 2,
'active' => true,
]);
$sorted = ProductAction::where('product_id', $product->id)
->orderBy('sort_order')
->get();
$this->assertEquals($action1->id, $sorted[0]->id);
$this->assertEquals($action2->id, $sorted[1]->id);
}
/** @test */
public function it_can_have_different_events()
{
$product = Product::factory()->create();
$purchasedAction = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\OnPurchase',
'active' => true,
]);
$refundedAction = ProductAction::create([
'product_id' => $product->id,
'event' => 'refunded',
'action_type' => 'App\\Actions\\OnRefund',
'active' => true,
]);
$this->assertEquals('purchased', $purchasedAction->event);
$this->assertEquals('refunded', $refundedAction->event);
}
/** @test */
public function it_can_get_actions_for_specific_event()
{
$product = Product::factory()->create();
ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\OnPurchase',
'active' => true,
]);
ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\AnotherPurchase',
'active' => true,
]);
ProductAction::create([
'product_id' => $product->id,
'event' => 'refunded',
'action_type' => 'App\\Actions\\OnRefund',
'active' => true,
]);
$purchaseActions = ProductAction::where('product_id', $product->id)
->where('event', 'purchased')
->get();
$this->assertCount(2, $purchaseActions);
}
/** @test */
public function it_can_filter_enabled_actions()
{
$product = Product::factory()->create();
ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\EnabledAction',
'active' => true,
]);
ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\DisabledAction',
'active' => false,
]);
$enabledActions = ProductAction::where('product_id', $product->id)
->where('active', true)
->get();
$this->assertCount(1, $enabledActions);
}
/** @test */
public function multiple_products_can_have_same_action()
{
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
ProductAction::create([
'product_id' => $product1->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\CommonAction',
'active' => true,
]);
ProductAction::create([
'product_id' => $product2->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\CommonAction',
'active' => true,
]);
$this->assertCount(1, $product1->actions);
$this->assertCount(1, $product2->actions);
}
/** @test */
public function it_can_update_action_parameters()
{
$product = Product::factory()->create();
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\TestAction',
'parameters' => ['key' => 'old_value'],
'active' => true,
]);
$action->update([
'parameters' => ['key' => 'new_value', 'another_key' => 'another_value'],
]);
$fresh = $action->fresh();
$this->assertEquals('new_value', $fresh->parameters['key']);
$this->assertEquals('another_value', $fresh->parameters['another_key']);
}
/** @test */
public function deleting_product_deletes_actions()
{
$product = Product::factory()->create();
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\TestAction',
'active' => true,
]);
$actionId = $action->id;
$product->delete();
$this->assertDatabaseMissing('product_actions', ['id' => $actionId]);
}
/** @test */
public function action_can_have_empty_parameters()
{
$product = Product::factory()->create();
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\SimpleAction',
'active' => true,
]);
$this->assertNull($action->parameters);
}
/** @test */
public function it_can_query_actions_by_priority_order()
{
$product = Product::factory()->create();
$high = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\HighPriority',
'sort_order' => 100,
'active' => true,
]);
$medium = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\MediumPriority',
'sort_order' => 50,
'active' => true,
]);
$low = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\LowPriority',
'sort_order' => 10,
'active' => true,
]);
$ordered = ProductAction::where('product_id', $product->id)
->orderBy('sort_order', 'asc')
->get();
$this->assertEquals($low->id, $ordered[0]->id);
$this->assertEquals($medium->id, $ordered[1]->id);
$this->assertEquals($high->id, $ordered[2]->id);
}
}

View File

@ -0,0 +1,228 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductCategory;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ProductCategoryTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_can_create_a_category()
{
$category = ProductCategory::factory()->create([
'slug' => 'electronics',
]);
$this->assertDatabaseHas('product_categories', [
'id' => $category->id,
'slug' => 'electronics',
]);
}
/** @test */
public function it_automatically_generates_slug_from_name()
{
$category = ProductCategory::create([
'slug' => null,
]);
$this->assertNotNull($category->slug);
}
/** @test */
public function it_can_have_a_parent_category()
{
$parent = ProductCategory::factory()->create([
'slug' => 'parent-category',
]);
$child = ProductCategory::factory()->create([
'slug' => 'child-category',
'parent_id' => $parent->id,
]);
$this->assertEquals($parent->id, $child->parent->id);
}
/** @test */
public function it_can_have_multiple_children()
{
$parent = ProductCategory::factory()->create();
$child1 = ProductCategory::factory()->create(['parent_id' => $parent->id]);
$child2 = ProductCategory::factory()->create(['parent_id' => $parent->id]);
$child3 = ProductCategory::factory()->create(['parent_id' => $parent->id]);
$this->assertCount(3, $parent->fresh()->children);
}
/** @test */
public function it_can_attach_products_to_category()
{
$category = ProductCategory::factory()->create();
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
$category->products()->attach([$product1->id, $product2->id]);
$this->assertCount(2, $category->fresh()->products);
$this->assertTrue($category->products->contains($product1));
$this->assertTrue($category->products->contains($product2));
}
/** @test */
public function it_can_count_products_in_category()
{
$category = ProductCategory::factory()->create();
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
$product3 = Product::factory()->create();
$category->products()->attach([$product1->id, $product2->id, $product3->id]);
$this->assertEquals(3, $category->products()->count());
}
/** @test */
public function it_can_check_visibility()
{
$visibleCategory = ProductCategory::factory()->create([
'visible' => true,
]);
$hiddenCategory = ProductCategory::factory()->create([
'visible' => false,
]);
$this->assertTrue($visibleCategory->is_visible);
$this->assertFalse($hiddenCategory->is_visible);
}
/** @test */
public function it_can_have_a_sort_order()
{
$category1 = ProductCategory::factory()->create(['sort_order' => 1]);
$category2 = ProductCategory::factory()->create(['sort_order' => 2]);
$category3 = ProductCategory::factory()->create(['sort_order' => 3]);
$sorted = ProductCategory::orderBy('sort_order')->get();
$this->assertEquals($category1->id, $sorted[0]->id);
$this->assertEquals($category2->id, $sorted[1]->id);
$this->assertEquals($category3->id, $sorted[2]->id);
}
/** @test */
public function it_can_store_meta_data()
{
$category = ProductCategory::factory()->create([
'meta' => [
'description' => 'Test description',
'keywords' => ['test', 'category'],
],
]);
$this->assertEquals('Test description', $category->meta->description);
$this->assertEquals(['test', 'category'], $category->meta->keywords);
}
/** @test */
public function product_can_belong_to_multiple_categories()
{
$product = Product::factory()->create();
$category1 = ProductCategory::factory()->create(['slug' => 'electronics']);
$category2 = ProductCategory::factory()->create(['slug' => 'gadgets']);
$category3 = ProductCategory::factory()->create(['slug' => 'accessories']);
$product->categories()->attach([$category1->id, $category2->id, $category3->id]);
$this->assertCount(3, $product->fresh()->categories);
}
/** @test */
public function it_can_get_all_products_from_category_hierarchy()
{
$parent = ProductCategory::factory()->create();
$child = ProductCategory::factory()->create(['parent_id' => $parent->id]);
$parentProduct = Product::factory()->create();
$childProduct = Product::factory()->create();
$parent->products()->attach($parentProduct->id);
$child->products()->attach($childProduct->id);
$this->assertCount(1, $parent->products);
$this->assertCount(1, $child->products);
}
/** @test */
public function it_can_detach_products_from_category()
{
$category = ProductCategory::factory()->create();
$product = Product::factory()->create();
$category->products()->attach($product->id);
$this->assertCount(1, $category->fresh()->products);
$category->products()->detach($product->id);
$this->assertCount(0, $category->fresh()->products);
}
/** @test */
public function deleting_category_does_not_delete_products()
{
$category = ProductCategory::factory()->create();
$product = Product::factory()->create();
$category->products()->attach($product->id);
$productId = $product->id;
$category->delete();
$this->assertDatabaseHas('products', ['id' => $productId]);
}
/** @test */
public function it_can_scope_visible_categories()
{
ProductCategory::factory()->create(['is_visible' => true]);
ProductCategory::factory()->create(['is_visible' => true]);
ProductCategory::factory()->create(['is_visible' => false]);
$visible = ProductCategory::where('is_visible', true)->get();
$this->assertCount(2, $visible);
}
/** @test */
public function it_can_get_root_categories()
{
$root1 = ProductCategory::factory()->create(['parent_id' => null]);
$root2 = ProductCategory::factory()->create(['parent_id' => null]);
$child = ProductCategory::factory()->create(['parent_id' => $root1->id]);
$roots = ProductCategory::whereNull('parent_id')->get();
$this->assertCount(2, $roots);
$this->assertTrue($roots->contains($root1));
$this->assertTrue($roots->contains($root2));
$this->assertFalse($roots->contains($child));
}
/** @test */
public function it_maintains_category_hierarchy_integrity()
{
$grandparent = ProductCategory::factory()->create();
$parent = ProductCategory::factory()->create(['parent_id' => $grandparent->id]);
$child = ProductCategory::factory()->create(['parent_id' => $parent->id]);
$this->assertEquals($grandparent->id, $parent->parent->id);
$this->assertEquals($parent->id, $child->parent->id);
$this->assertNull($grandparent->parent);
}
}

View File

@ -0,0 +1,337 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductCategory;
use Blax\Shop\Models\ProductAttribute;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ProductManagementTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_can_create_a_product()
{
$product = Product::factory()->create([
'slug' => 'test-product',
'type' => 'simple',
'price' => 99.99,
'regular_price' => 99.99,
]);
$this->assertDatabaseHas('products', [
'id' => $product->id,
'slug' => 'test-product',
'price' => 99.99,
]);
}
/** @test */
public function it_automatically_generates_slug_if_not_provided()
{
$product = Product::factory()->create(['slug' => null]);
$this->assertNotNull($product->slug);
$this->assertStringStartsWith('new-product-', $product->slug);
}
/** @test */
public function it_returns_current_price_correctly()
{
$product = Product::factory()->create([
'regular_price' => 100,
'sale_price' => null,
]);
$this->assertEquals(100, $product->getCurrentPrice());
}
/** @test */
public function it_applies_sale_price_when_active()
{
$product = Product::factory()->create([
'regular_price' => 100,
'sale_price' => 75,
'sale_start' => now()->subDay(),
'sale_end' => now()->addDay(),
]);
$this->assertEquals(75, $product->getCurrentPrice());
}
/** @test */
public function it_ignores_sale_price_when_not_started()
{
$product = Product::factory()->create([
'regular_price' => 100,
'sale_price' => 75,
'sale_start' => now()->addDay(),
'sale_end' => now()->addWeek(),
]);
$this->assertEquals(100, $product->getCurrentPrice());
}
/** @test */
public function it_ignores_sale_price_when_ended()
{
$product = Product::factory()->create([
'regular_price' => 100,
'sale_price' => 75,
'sale_start' => now()->subWeek(),
'sale_end' => now()->subDay(),
]);
$this->assertEquals(100, $product->getCurrentPrice());
}
/** @test */
public function it_can_manage_stock()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 50,
'in_stock' => true,
]);
$this->assertTrue($product->increaseStock(10));
$this->assertEquals(60, $product->fresh()->stock_quantity);
$this->assertTrue($product->decreaseStock(5));
$this->assertEquals(55, $product->fresh()->stock_quantity);
}
/** @test */
public function it_cannot_decrease_stock_below_zero()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 5,
]);
$this->assertFalse($product->decreaseStock(10));
$this->assertEquals(5, $product->fresh()->stock_quantity);
}
/** @test */
public function it_returns_available_stock()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 100,
]);
$this->assertEquals(100, $product->getAvailableStock());
}
/** @test */
public function it_can_check_if_in_stock()
{
$productInStock = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 10,
'in_stock' => true,
]);
$productOutOfStock = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 0,
'in_stock' => false,
]);
$this->assertTrue($productInStock->isInStock());
$this->assertFalse($productOutOfStock->isInStock());
}
/** @test */
public function it_can_attach_categories()
{
$product = Product::factory()->create();
$category = ProductCategory::factory()->create();
$product->categories()->attach($category);
$this->assertTrue($product->categories->contains($category));
}
/** @test */
public function it_can_have_attributes()
{
$product = Product::factory()->create();
$attribute = ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Color',
'value' => 'Blue',
]);
$this->assertCount(1, $product->fresh()->attributes);
$this->assertEquals('Color', $product->attributes->first()->key);
$this->assertEquals('Blue', $product->attributes->first()->value);
}
/** @test */
public function it_can_have_multiple_prices()
{
$product = Product::factory()->create();
ProductPrice::create([
'product_id' => $product->id,
'type' => 'one-time',
'price' => 9999,
'currency' => 'USD',
'active' => true,
]);
ProductPrice::create([
'product_id' => $product->id,
'type' => 'recurring',
'price' => 1999,
'currency' => 'USD',
'interval' => 'month',
'active' => true,
]);
$this->assertCount(2, $product->fresh()->prices);
}
/** @test */
public function it_can_have_related_products()
{
$product = Product::factory()->create();
$relatedProduct = Product::factory()->create();
$product->relatedProducts()->attach($relatedProduct->id, [
'type' => 'related',
]);
$this->assertTrue($product->relatedProducts->contains($relatedProduct));
}
/** @test */
public function it_can_have_upsell_products()
{
$product = Product::factory()->create();
$upsellProduct = Product::factory()->create();
$product->relatedProducts()->attach($upsellProduct->id, [
'type' => 'upsell',
]);
$this->assertTrue($product->upsells->contains($upsellProduct));
}
/** @test */
public function it_can_have_cross_sell_products()
{
$product = Product::factory()->create();
$crossSellProduct = Product::factory()->create();
$product->relatedProducts()->attach($crossSellProduct->id, [
'type' => 'cross-sell',
]);
$this->assertTrue($product->crossSells->contains($crossSellProduct));
}
/** @test */
public function it_can_scope_published_products()
{
Product::factory()->create(['status' => 'published']);
Product::factory()->create(['status' => 'draft']);
$published = Product::published()->get();
$this->assertCount(1, $published);
$this->assertEquals('published', $published->first()->status);
}
/** @test */
public function it_can_scope_in_stock_products()
{
Product::factory()->create([
'in_stock' => true,
'manage_stock' => true,
'stock_quantity' => 10,
]);
Product::factory()->create([
'in_stock' => false,
'manage_stock' => true,
'stock_quantity' => 0,
]);
$inStock = Product::inStock()->get();
$this->assertCount(1, $inStock);
$this->assertTrue($inStock->first()->in_stock);
}
/** @test */
public function it_can_scope_visible_products()
{
Product::factory()->create([
'visible' => true,
'status' => 'published',
]);
Product::factory()->create([
'visible' => false,
'status' => 'published',
]);
$visible = Product::visible()->get();
$this->assertCount(1, $visible);
$this->assertTrue($visible->first()->visible);
}
/** @test */
public function it_can_have_parent_child_relationships()
{
$parent = Product::factory()->create([
'type' => 'variable',
]);
$child = Product::factory()->create([
'type' => 'variation',
'parent_id' => $parent->id,
]);
$this->assertTrue($parent->children->contains($child));
$this->assertEquals($parent->id, $child->parent->id);
}
/** @test */
public function it_validates_virtual_and_downloadable_flags()
{
$virtualProduct = Product::factory()->create([
'virtual' => true,
'downloadable' => false,
]);
$downloadableProduct = Product::factory()->create([
'virtual' => false,
'downloadable' => true,
]);
$this->assertTrue($virtualProduct->virtual);
$this->assertFalse($virtualProduct->downloadable);
$this->assertTrue($downloadableProduct->downloadable);
$this->assertFalse($downloadableProduct->virtual);
}
/** @test */
public function it_can_check_featured_status()
{
$featured = Product::factory()->create(['featured' => true]);
$regular = Product::factory()->create(['featured' => false]);
$this->assertTrue($featured->featured);
$this->assertFalse($regular->featured);
}
}

View File

@ -0,0 +1,322 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\Cart;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
class PurchaseFlowTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function user_can_purchase_a_product_directly()
{
$user = User::factory()->create();
$product = Product::factory()->create([
'price' => 99.99,
'manage_stock' => false,
]);
$purchase = $user->purchase($product, quantity: 1);
$this->assertInstanceOf(ProductPurchase::class, $purchase);
$this->assertEquals($product->id, $purchase->product_id);
$this->assertEquals($user->id, $purchase->user_id);
$this->assertEquals(1, $purchase->quantity);
$this->assertEquals('completed', $purchase->status);
}
/** @test */
public function user_can_add_product_to_cart()
{
$user = User::factory()->create();
$product = Product::factory()->create([
'price' => 49.99,
'manage_stock' => false,
]);
$cartItem = $user->addToCart($product, quantity: 2);
$this->assertInstanceOf(ProductPurchase::class, $cartItem);
$this->assertEquals('cart', $cartItem->status);
$this->assertEquals(2, $cartItem->quantity);
$this->assertEquals($product->id, $cartItem->product_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]);
$user->addToCart($product1, quantity: 1);
$user->addToCart($product2, quantity: 2);
$cartItems = $user->cartItems;
$this->assertCount(2, $cartItems);
}
/** @test */
public function user_can_update_cart_item_quantity()
{
$user = User::factory()->create();
$product = Product::factory()->create([
'price' => 50.00,
'manage_stock' => false,
]);
$cartItem = $user->addToCart($product, quantity: 1);
$user->updateCartQuantity($cartItem, quantity: 5);
$this->assertEquals(5, $cartItem->fresh()->quantity);
}
/** @test */
public function user_can_remove_item_from_cart()
{
$user = User::factory()->create();
$product = Product::factory()->create();
$cartItem = $user->addToCart($product, quantity: 1);
$this->assertCount(1, $user->fresh()->cartItems);
$user->removeFromCart($cartItem);
$this->assertCount(0, $user->fresh()->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,
]);
$user->addToCart($product1, quantity: 2);
$user->addToCart($product2, quantity: 1);
$purchases = $user->checkout();
$this->assertCount(2, $purchases);
$this->assertEquals('completed', $purchases[0]->status);
$this->assertEquals('completed', $purchases[1]->status);
}
/** @test */
public function user_can_get_cart_total()
{
$user = User::factory()->create();
$product1 = Product::factory()->create(['price' => 40.00]);
$product2 = Product::factory()->create(['price' => 60.00]);
$user->addToCart($product1, quantity: 2); // 80.00
$user->addToCart($product2, quantity: 1); // 60.00
$total = $user->getCartTotal();
$this->assertEquals(140.00, $total);
}
/** @test */
public function user_can_get_cart_items_count()
{
$user = User::factory()->create();
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
$user->addToCart($product1, quantity: 3);
$user->addToCart($product2, quantity: 2);
$count = $user->getCartItemsCount();
$this->assertEquals(5, $count);
}
/** @test */
public function user_can_clear_cart()
{
$user = User::factory()->create();
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
$user->addToCart($product1, quantity: 1);
$user->addToCart($product2, quantity: 1);
$this->assertCount(2, $user->cartItems);
$user->clearCart();
$this->assertCount(0, $user->fresh()->cartItems);
}
/** @test */
public function user_can_check_if_product_was_purchased()
{
$user = User::factory()->create();
$purchasedProduct = Product::factory()->create(['manage_stock' => false]);
$notPurchasedProduct = Product::factory()->create();
$user->purchase($purchasedProduct, quantity: 1);
$this->assertTrue($user->hasPurchased($purchasedProduct));
$this->assertFalse($user->hasPurchased($notPurchasedProduct));
}
/** @test */
public function user_can_get_completed_purchases()
{
$user = User::factory()->create();
$product1 = Product::factory()->create(['manage_stock' => false]);
$product2 = Product::factory()->create(['manage_stock' => false]);
$product3 = Product::factory()->create();
$user->purchase($product1, quantity: 1);
$user->purchase($product2, quantity: 1);
$user->addToCart($product3, quantity: 1);
$completed = $user->completedPurchases;
$this->assertCount(2, $completed);
}
/** @test */
public function purchase_reduces_stock_when_managed()
{
$user = User::factory()->create();
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 10,
]);
$user->purchase($product, quantity: 3);
$this->assertEquals(7, $product->fresh()->stock_quantity);
}
/** @test */
public function cannot_purchase_more_than_available_stock()
{
$user = User::factory()->create();
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 5,
]);
$this->expectException(\Exception::class);
$user->purchase($product, quantity: 10);
}
/** @test */
public function adding_to_cart_checks_stock_availability()
{
$user = User::factory()->create();
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 3,
]);
$this->expectException(\Exception::class);
$user->addToCart($product, quantity: 5);
}
/** @test */
public function purchase_can_store_metadata()
{
$user = User::factory()->create();
$product = Product::factory()->create(['manage_stock' => false]);
$purchase = $user->purchase($product, quantity: 1, options: [
'meta' => [
'gift_message' => 'Happy Birthday!',
'gift_wrap' => true,
],
]);
$this->assertEquals('Happy Birthday!', $purchase->meta['gift_message'] ?? null);
}
/** @test */
public function purchase_can_be_associated_with_cart()
{
$user = User::factory()->create();
$cart = Cart::create(['user_id' => $user->id]);
$product = Product::factory()->create(['manage_stock' => false]);
$purchase = ProductPurchase::create([
'user_id' => $user->id,
'purchasable_type' => get_class($user),
'purchasable_id' => $user->id,
'product_id' => $product->id,
'cart_id' => $cart->id,
'quantity' => 1,
'amount' => 5000,
'status' => 'cart',
]);
$this->assertEquals($cart->id, $purchase->cart_id);
$this->assertTrue($cart->purchases->contains($purchase));
}
/** @test */
public function checkout_marks_cart_as_converted()
{
$user = User::factory()->create();
$product = Product::factory()->create(['manage_stock' => false]);
$cartItem = $user->addToCart($product, quantity: 1);
$cart = Cart::where('user_id', $user->id)->first();
if ($cart) {
$this->assertNull($cart->converted_at);
$user->checkout();
$this->assertNotNull($cart->fresh()->converted_at);
}
}
/** @test */
public function user_cannot_add_out_of_stock_product_to_cart()
{
$user = User::factory()->create();
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 0,
'in_stock' => false,
]);
$this->expectException(\Exception::class);
$user->addToCart($product, quantity: 1);
}
/** @test */
public function purchase_stores_amount_correctly()
{
$user = User::factory()->create();
$product = Product::factory()->create([
'price' => 49.99,
'manage_stock' => false,
]);
$purchase = $user->purchase($product, quantity: 2);
$this->assertGreaterThan(0, $purchase->amount);
}
}

View File

@ -0,0 +1,321 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class StockManagementTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_can_reserve_stock_for_a_product()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 100,
]);
$reservation = ProductStock::reserve(
product: $product,
quantity: 10,
type: 'reservation',
until: now()->addHours(2)
);
$this->assertNotNull($reservation);
$this->assertEquals(10, $reservation->quantity);
$this->assertEquals(90, $product->fresh()->stock_quantity);
}
/** @test */
public function it_cannot_reserve_more_stock_than_available()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 5,
]);
$reservation = ProductStock::reserve(
product: $product,
quantity: 10,
type: 'reservation'
);
$this->assertNull($reservation);
$this->assertEquals(5, $product->fresh()->stock_quantity);
}
/** @test */
public function it_can_release_reserved_stock()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 100,
]);
$reservation = ProductStock::reserve(
product: $product,
quantity: 10,
type: 'reservation'
);
$this->assertEquals(90, $product->fresh()->stock_quantity);
$reservation->release();
$this->assertEquals(100, $product->fresh()->stock_quantity);
$this->assertNotNull($reservation->fresh()->released_at);
}
/** @test */
public function it_can_check_if_stock_is_pending()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 50,
]);
$reservation = ProductStock::reserve(
product: $product,
quantity: 5,
type: 'reservation'
);
$pending = ProductStock::pending()->where('id', $reservation->id)->first();
$this->assertNotNull($pending);
$this->assertNull($pending->released_at);
}
/** @test */
public function it_can_check_if_stock_is_released()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 50,
]);
$reservation = ProductStock::reserve(
product: $product,
quantity: 5,
type: 'reservation'
);
$reservation->release();
$released = ProductStock::released()->where('id', $reservation->id)->first();
$this->assertNotNull($released);
$this->assertNotNull($released->released_at);
}
/** @test */
public function it_can_find_expired_reservations()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 100,
]);
$expiredReservation = ProductStock::reserve(
product: $product,
quantity: 10,
type: 'reservation',
until: now()->subHour()
);
$activeReservation = ProductStock::reserve(
product: $product,
quantity: 5,
type: 'reservation',
until: now()->addHour()
);
$expired = ProductStock::expired()->get();
$this->assertTrue($expired->contains($expiredReservation));
$this->assertFalse($expired->contains($activeReservation));
}
/** @test */
public function it_can_distinguish_temporary_and_permanent_reservations()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 100,
]);
$temporary = ProductStock::reserve(
product: $product,
quantity: 10,
type: 'reservation',
until: now()->addHours(2)
);
$permanent = ProductStock::reserve(
product: $product,
quantity: 5,
type: 'sold'
);
$temporaryReservations = ProductStock::temporary()->get();
$permanentReservations = ProductStock::permanent()->get();
$this->assertTrue($temporaryReservations->contains($temporary));
$this->assertFalse($temporaryReservations->contains($permanent));
$this->assertTrue($permanentReservations->contains($permanent));
$this->assertFalse($permanentReservations->contains($temporary));
}
/** @test */
public function it_belongs_to_a_product()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 50,
]);
$stock = ProductStock::reserve(
product: $product,
quantity: 5,
type: 'reservation'
);
$this->assertEquals($product->id, $stock->product->id);
}
/** @test */
public function product_has_many_stock_records()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 100,
]);
ProductStock::reserve($product, quantity: 10, type: 'reservation');
ProductStock::reserve($product, quantity: 5, type: 'reservation');
ProductStock::reserve($product, quantity: 3, type: 'sold');
$this->assertCount(3, $product->fresh()->stocks);
}
/** @test */
public function it_can_get_active_stock_reservations()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 100,
]);
$active1 = ProductStock::reserve($product, quantity: 10, type: 'reservation');
$active2 = ProductStock::reserve($product, quantity: 5, type: 'reservation');
$released = ProductStock::reserve($product, quantity: 3, type: 'sold');
$released->release();
$activeStocks = $product->fresh()->activeStocks;
$this->assertCount(2, $activeStocks);
$this->assertTrue($activeStocks->contains($active1));
$this->assertTrue($activeStocks->contains($active2));
$this->assertFalse($activeStocks->contains($released));
}
/** @test */
public function it_cannot_release_stock_twice()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 50,
]);
$reservation = ProductStock::reserve($product, quantity: 10, type: 'reservation');
$this->assertTrue($reservation->release());
$this->assertFalse($reservation->release());
}
/** @test */
public function it_can_store_reservation_note()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 50,
]);
$reservation = ProductStock::reserve(
product: $product,
quantity: 5,
type: 'reservation',
note: 'Reserved for order #12345'
);
$this->assertEquals('Reserved for order #12345', $reservation->note);
}
/** @test */
public function it_handles_stock_transactions_atomically()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 10,
]);
// Try to reserve more than available
$reservation = ProductStock::reserve($product, quantity: 15, type: 'reservation');
// Should fail and not change stock
$this->assertNull($reservation);
$this->assertEquals(10, $product->fresh()->stock_quantity);
}
/** @test */
public function it_calculates_available_stock_correctly()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 100,
]);
// Reserve some stock
ProductStock::reserve($product, quantity: 20, type: 'reservation');
ProductStock::reserve($product, quantity: 10, type: 'reservation');
$available = $product->fresh()->stock_quantity;
$this->assertEquals(70, $available);
}
/** @test */
public function product_tracks_low_stock_threshold()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 15,
'low_stock_threshold' => 10,
]);
$this->assertFalse($product->isLowStock());
$product->decreaseStock(8);
$this->assertTrue($product->fresh()->isLowStock());
}
/** @test */
public function it_updates_in_stock_status_automatically()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 5,
'in_stock' => true,
]);
$product->decreaseStock(5);
$this->assertFalse($product->fresh()->in_stock);
}
}

61
tests/TestCase.php Normal file
View File

@ -0,0 +1,61 @@
<?php
namespace Blax\Shop\Tests;
use Blax\Shop\ShopServiceProvider;
use Illuminate\Database\Eloquent\Factories\Factory;
use Orchestra\Testbench\TestCase as Orchestra;
abstract class TestCase extends Orchestra
{
protected function setUp(): void
{
parent::setUp();
Factory::guessFactoryNamesUsing(
fn(string $modelName) => match (true) {
str_starts_with($modelName, 'Workbench\\App\\') => 'Workbench\\Database\\Factories\\' . class_basename($modelName) . 'Factory',
default => 'Blax\\Shop\\Database\\Factories\\' . class_basename($modelName) . 'Factory'
}
);
}
protected function getPackageProviders($app)
{
return [
ShopServiceProvider::class,
];
}
public function getEnvironmentSetUp($app)
{
config()->set('database.default', 'testing');
config()->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
// Set up i18n config for HasMetaTranslation trait
config()->set('app.i18n.supporting', [
'en' => 'English',
'es' => 'Spanish',
'fr' => 'French',
]);
// Create users table for testing
$app['db']->connection()->getSchemaBuilder()->create('users', function ($table) {
$table->uuid('id')->primary();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
// Run package migrations
$migration = include __DIR__ . '/../database/migrations/create_blax_shop_tables.php.stub';
$migration->up();
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ProductPricingTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_returns_regular_price_when_not_on_sale()
{
$product = Product::factory()->create([
'regular_price' => 100,
'sale_price' => null,
]);
$this->assertEquals(100, $product->getCurrentPrice());
}
/** @test */
public function it_returns_sale_price_when_on_sale()
{
$product = Product::factory()->create([
'regular_price' => 100,
'sale_price' => 80,
'sale_start' => now()->subDay(),
'sale_end' => now()->addDay(),
]);
$this->assertEquals(80, $product->getCurrentPrice());
}
/** @test */
public function it_returns_regular_price_when_sale_has_ended()
{
$product = Product::factory()->create([
'regular_price' => 100,
'sale_price' => 80,
'sale_start' => now()->subDays(7),
'sale_end' => now()->subDay(),
]);
$this->assertEquals(100, $product->getCurrentPrice());
}
/** @test */
public function it_returns_regular_price_when_sale_hasnt_started()
{
$product = Product::factory()->create([
'regular_price' => 100,
'sale_price' => 80,
'sale_start' => now()->addDay(),
'sale_end' => now()->addWeek(),
]);
$this->assertEquals(100, $product->getCurrentPrice());
}
/** @test */
public function it_calculates_discount_percentage()
{
$product = Product::factory()->create([
'regular_price' => 100,
'sale_price' => 75,
]);
$discount = (($product->regular_price - $product->sale_price) / $product->regular_price) * 100;
$this->assertEquals(25, $discount);
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class StockManagementTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_detects_low_stock()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 5,
'low_stock_threshold' => 10,
]);
$this->assertTrue($product->isLowStock());
}
/** @test */
public function it_detects_sufficient_stock()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 50,
'low_stock_threshold' => 10,
]);
$this->assertFalse($product->isLowStock());
}
/** @test */
public function it_marks_product_as_out_of_stock()
{
$product = Product::factory()->create([
'manage_stock' => true,
'stock_quantity' => 0,
'in_stock' => false,
'stock_status' => 'outofstock',
]);
$this->assertFalse($product->in_stock);
$this->assertEquals('outofstock', $product->stock_status);
}
/** @test */
public function products_without_stock_management_are_always_in_stock()
{
$product = Product::factory()->create([
'manage_stock' => false,
'stock_quantity' => 0,
]);
// When stock management is disabled, product should be considered in stock
$this->assertFalse($product->manage_stock);
}
}