init
This commit is contained in:
commit
d610cc5717
|
|
@ -0,0 +1,2 @@
|
||||||
|
docker
|
||||||
|
.vscode
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
/.vscode export-ignore
|
||||||
|
/.gitattributes export-ignore
|
||||||
|
/.gitignore export-ignore
|
||||||
|
/phpunit.xml.dist export-ignore
|
||||||
|
/docs export-ignore
|
||||||
|
/tests export-ignore
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"[php]": {
|
||||||
|
"editor.defaultFormatter": "bmewburn.vscode-intelephense-client"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 "$@"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue