style updates

This commit is contained in:
Mark Gregory
2026-07-24 14:59:44 +01:00
parent 24ab771cd3
commit dd23205d0b
482 changed files with 7173 additions and 1435 deletions
+1
View File
@@ -1,4 +1,5 @@
<?php
return [
'currency' => env('COMMERCE_CURRENCY', 'GBP'),
'currency_symbol' => env('COMMERCE_CURRENCY_SYMBOL', '£'),
@@ -1,4 +1,12 @@
<?php
namespace Modules\Commerce\Contracts;
use Modules\Commerce\Models\Product;
interface InventoryProvider { public function adjust(Product $product, int $quantity, string $reason, ?int $userId = null): void; public function available(Product $product): int; }
interface InventoryProvider
{
public function adjust(Product $product, int $quantity, string $reason, ?int $userId = null): void;
public function available(Product $product): int;
}
@@ -1,4 +1,12 @@
<?php
namespace Modules\Commerce\Contracts;
use Modules\Commerce\Models\Order;
interface PaymentProvider { public function charge(Order $order, array $data = []): array; public function refund(Order $order, float $amount): array; }
interface PaymentProvider
{
public function charge(Order $order, array $data = []): array;
public function refund(Order $order, float $amount): array;
}
@@ -1,3 +1,8 @@
<?php
namespace Modules\Commerce\Contracts;
interface ShippingProvider { public function quote(array $address, float $subtotal): float; }
interface ShippingProvider
{
public function quote(array $address, float $subtotal): float;
}
+6 -1
View File
@@ -1,3 +1,8 @@
<?php
namespace Modules\Commerce\Contracts;
interface TaxProvider { public function calculate(float $subtotal, array $address = []): float; }
interface TaxProvider
{
public function calculate(float $subtotal, array $address = []): float;
}
@@ -1,24 +1,172 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
return new class extends Migration
{
public function up(): void
{
Schema::create('commerce_categories', function (Blueprint $table) { $table->id(); $table->foreignId('parent_id')->nullable()->constrained('commerce_categories')->nullOnDelete(); $table->string('name'); $table->string('slug')->unique(); $table->text('description')->nullable(); $table->boolean('is_active')->default(true); $table->unsignedInteger('sort_order')->default(0); $table->timestamps(); });
Schema::create('commerce_products', function (Blueprint $table) { $table->id(); $table->foreignId('category_id')->nullable()->constrained('commerce_categories')->nullOnDelete(); $table->string('type')->default('physical'); $table->string('name'); $table->string('slug')->unique(); $table->string('sku')->nullable()->unique(); $table->text('summary')->nullable(); $table->longText('description')->nullable(); $table->decimal('price', 12, 2)->default(0); $table->decimal('compare_at_price', 12, 2)->nullable(); $table->decimal('cost_price', 12, 2)->nullable(); $table->string('currency', 3)->default('GBP'); $table->boolean('track_inventory')->default(true); $table->integer('stock_quantity')->default(0); $table->integer('low_stock_threshold')->default(5); $table->boolean('is_featured')->default(false); $table->string('status')->default('draft'); $table->timestamp('published_at')->nullable(); $table->json('metadata')->nullable(); $table->timestamps(); $table->softDeletes(); $table->index(['status','published_at']); });
Schema::create('commerce_product_variants', function (Blueprint $table) { $table->id(); $table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete(); $table->string('name'); $table->string('sku')->unique(); $table->decimal('price', 12, 2)->nullable(); $table->integer('stock_quantity')->default(0); $table->json('options')->nullable(); $table->boolean('is_active')->default(true); $table->timestamps(); });
Schema::create('commerce_customers', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->string('email')->index(); $table->string('first_name'); $table->string('last_name'); $table->string('company')->nullable(); $table->string('phone')->nullable(); $table->string('tax_number')->nullable(); $table->json('billing_address')->nullable(); $table->json('shipping_address')->nullable(); $table->string('status')->default('active'); $table->timestamps(); });
Schema::create('commerce_orders', function (Blueprint $table) { $table->id(); $table->string('number')->unique(); $table->foreignId('customer_id')->nullable()->constrained('commerce_customers')->nullOnDelete(); $table->string('email'); $table->string('status')->default('pending'); $table->string('payment_status')->default('unpaid'); $table->string('fulfilment_status')->default('unfulfilled'); $table->string('currency',3)->default('GBP'); $table->decimal('subtotal',12,2)->default(0); $table->decimal('discount_total',12,2)->default(0); $table->decimal('shipping_total',12,2)->default(0); $table->decimal('tax_total',12,2)->default(0); $table->decimal('grand_total',12,2)->default(0); $table->json('billing_address')->nullable(); $table->json('shipping_address')->nullable(); $table->text('notes')->nullable(); $table->timestamp('placed_at')->nullable(); $table->timestamps(); $table->index(['status','payment_status']); });
Schema::create('commerce_order_items', function (Blueprint $table) { $table->id(); $table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete(); $table->foreignId('product_id')->nullable()->constrained('commerce_products')->nullOnDelete(); $table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->nullOnDelete(); $table->string('name'); $table->string('sku')->nullable(); $table->unsignedInteger('quantity'); $table->decimal('unit_price',12,2); $table->decimal('tax_total',12,2)->default(0); $table->decimal('line_total',12,2); $table->json('metadata')->nullable(); $table->timestamps(); });
Schema::create('commerce_inventory_movements', function (Blueprint $table) { $table->id(); $table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete(); $table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->cascadeOnDelete(); $table->integer('quantity'); $table->string('reason'); $table->string('reference_type')->nullable(); $table->unsignedBigInteger('reference_id')->nullable(); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->text('notes')->nullable(); $table->timestamps(); $table->index(['reference_type','reference_id']); });
Schema::create('commerce_promotions', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('code')->nullable()->unique(); $table->string('type')->default('percentage'); $table->decimal('value',12,2); $table->decimal('minimum_order',12,2)->nullable(); $table->unsignedInteger('usage_limit')->nullable(); $table->unsignedInteger('times_used')->default(0); $table->timestamp('starts_at')->nullable(); $table->timestamp('ends_at')->nullable(); $table->boolean('is_active')->default(true); $table->json('conditions')->nullable(); $table->timestamps(); });
Schema::create('commerce_shipping_methods', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('code')->unique(); $table->decimal('price',12,2)->default(0); $table->decimal('free_over',12,2)->nullable(); $table->boolean('is_active')->default(true); $table->json('settings')->nullable(); $table->timestamps(); });
Schema::create('commerce_tax_rates', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('country_code',2)->nullable(); $table->decimal('rate',7,4)->default(0); $table->boolean('is_default')->default(false); $table->boolean('is_active')->default(true); $table->timestamps(); });
Schema::create('commerce_payment_transactions', function (Blueprint $table) { $table->id(); $table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete(); $table->string('provider'); $table->string('provider_reference')->nullable()->index(); $table->string('type')->default('payment'); $table->string('status')->default('pending'); $table->decimal('amount',12,2); $table->string('currency',3); $table->json('payload')->nullable(); $table->timestamps(); });
Schema::create('commerce_categories', function (Blueprint $table) {
$table->id();
$table->foreignId('parent_id')->nullable()->constrained('commerce_categories')->nullOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
});
Schema::create('commerce_products', function (Blueprint $table) {
$table->id();
$table->foreignId('category_id')->nullable()->constrained('commerce_categories')->nullOnDelete();
$table->string('type')->default('physical');
$table->string('name');
$table->string('slug')->unique();
$table->string('sku')->nullable()->unique();
$table->text('summary')->nullable();
$table->longText('description')->nullable();
$table->decimal('price', 12, 2)->default(0);
$table->decimal('compare_at_price', 12, 2)->nullable();
$table->decimal('cost_price', 12, 2)->nullable();
$table->string('currency', 3)->default('GBP');
$table->boolean('track_inventory')->default(true);
$table->integer('stock_quantity')->default(0);
$table->integer('low_stock_threshold')->default(5);
$table->boolean('is_featured')->default(false);
$table->string('status')->default('draft');
$table->timestamp('published_at')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'published_at']);
});
Schema::create('commerce_product_variants', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete();
$table->string('name');
$table->string('sku')->unique();
$table->decimal('price', 12, 2)->nullable();
$table->integer('stock_quantity')->default(0);
$table->json('options')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('commerce_customers', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('email')->index();
$table->string('first_name');
$table->string('last_name');
$table->string('company')->nullable();
$table->string('phone')->nullable();
$table->string('tax_number')->nullable();
$table->json('billing_address')->nullable();
$table->json('shipping_address')->nullable();
$table->string('status')->default('active');
$table->timestamps();
});
Schema::create('commerce_orders', function (Blueprint $table) {
$table->id();
$table->string('number')->unique();
$table->foreignId('customer_id')->nullable()->constrained('commerce_customers')->nullOnDelete();
$table->string('email');
$table->string('status')->default('pending');
$table->string('payment_status')->default('unpaid');
$table->string('fulfilment_status')->default('unfulfilled');
$table->string('currency', 3)->default('GBP');
$table->decimal('subtotal', 12, 2)->default(0);
$table->decimal('discount_total', 12, 2)->default(0);
$table->decimal('shipping_total', 12, 2)->default(0);
$table->decimal('tax_total', 12, 2)->default(0);
$table->decimal('grand_total', 12, 2)->default(0);
$table->json('billing_address')->nullable();
$table->json('shipping_address')->nullable();
$table->text('notes')->nullable();
$table->timestamp('placed_at')->nullable();
$table->timestamps();
$table->index(['status', 'payment_status']);
});
Schema::create('commerce_order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete();
$table->foreignId('product_id')->nullable()->constrained('commerce_products')->nullOnDelete();
$table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->nullOnDelete();
$table->string('name');
$table->string('sku')->nullable();
$table->unsignedInteger('quantity');
$table->decimal('unit_price', 12, 2);
$table->decimal('tax_total', 12, 2)->default(0);
$table->decimal('line_total', 12, 2);
$table->json('metadata')->nullable();
$table->timestamps();
});
Schema::create('commerce_inventory_movements', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete();
$table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->cascadeOnDelete();
$table->integer('quantity');
$table->string('reason');
$table->string('reference_type')->nullable();
$table->unsignedBigInteger('reference_id')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['reference_type', 'reference_id']);
});
Schema::create('commerce_promotions', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('code')->nullable()->unique();
$table->string('type')->default('percentage');
$table->decimal('value', 12, 2);
$table->decimal('minimum_order', 12, 2)->nullable();
$table->unsignedInteger('usage_limit')->nullable();
$table->unsignedInteger('times_used')->default(0);
$table->timestamp('starts_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->boolean('is_active')->default(true);
$table->json('conditions')->nullable();
$table->timestamps();
});
Schema::create('commerce_shipping_methods', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('code')->unique();
$table->decimal('price', 12, 2)->default(0);
$table->decimal('free_over', 12, 2)->nullable();
$table->boolean('is_active')->default(true);
$table->json('settings')->nullable();
$table->timestamps();
});
Schema::create('commerce_tax_rates', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('country_code', 2)->nullable();
$table->decimal('rate', 7, 4)->default(0);
$table->boolean('is_default')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('commerce_payment_transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete();
$table->string('provider');
$table->string('provider_reference')->nullable()->index();
$table->string('type')->default('payment');
$table->string('status')->default('pending');
$table->decimal('amount', 12, 2);
$table->string('currency', 3);
$table->json('payload')->nullable();
$table->timestamps();
});
}
public function down(): void
{
foreach (['commerce_payment_transactions','commerce_tax_rates','commerce_shipping_methods','commerce_promotions','commerce_inventory_movements','commerce_order_items','commerce_orders','commerce_customers','commerce_product_variants','commerce_products','commerce_categories'] as $table) Schema::dropIfExists($table);
foreach (['commerce_payment_transactions', 'commerce_tax_rates', 'commerce_shipping_methods', 'commerce_promotions', 'commerce_inventory_movements', 'commerce_order_items', 'commerce_orders', 'commerce_customers', 'commerce_product_variants', 'commerce_products', 'commerce_categories'] as $table) {
Schema::dropIfExists($table);
}
}
};
@@ -1,5 +1,7 @@
<?php
namespace Modules\Commerce\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -9,15 +11,43 @@ use Modules\Commerce\Models\Customer;
use Modules\Commerce\Models\Order;
use Modules\Commerce\Models\Product;
use Modules\Commerce\Models\Promotion;
final class CommerceController extends Controller
{
public function index(): View { return view('commerce::admin.index',['products'=>Product::latest()->paginate(20),'orders'=>Order::latest()->limit(10)->get(),'revenue'=>Order::where('payment_status','paid')->sum('grand_total'),'lowStock'=>Product::where('track_inventory',true)->whereColumn('stock_quantity','<=','low_stock_threshold')->count(),'customers'=>Customer::count(),'promotions'=>Promotion::where('is_active',true)->count()]); }
public function index(): View
{
return view('commerce::admin.index', ['products' => Product::latest()->paginate(20), 'orders' => Order::latest()->limit(10)->get(), 'revenue' => Order::where('payment_status', 'paid')->sum('grand_total'), 'lowStock' => Product::where('track_inventory', true)->whereColumn('stock_quantity', '<=', 'low_stock_threshold')->count(), 'customers' => Customer::count(), 'promotions' => Promotion::where('is_active', true)->count()]);
}
public function storeProduct(Request $request): RedirectResponse
{
$data=$request->validate(['name'=>['required','string','max:255'],'slug'=>['nullable','string','max:255','unique:commerce_products,slug'],'sku'=>['nullable','string','max:120','unique:commerce_products,sku'],'type'=>['required','in:physical,digital,service,bundle'],'summary'=>['nullable','string'],'description'=>['nullable','string'],'price'=>['required','numeric','min:0'],'stock_quantity'=>['required','integer'],'status'=>['required','in:draft,active,archived']]);
$data['slug']=$data['slug'] ?: Str::slug($data['name']); $data['currency']=config('commerce.currency','GBP'); $data['published_at']=$data['status']==='active'?now():null; Product::create($data); return back()->with('status','Product created.');
$data = $request->validate(['name' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255', 'unique:commerce_products,slug'], 'sku' => ['nullable', 'string', 'max:120', 'unique:commerce_products,sku'], 'type' => ['required', 'in:physical,digital,service,bundle'], 'summary' => ['nullable', 'string'], 'description' => ['nullable', 'string'], 'price' => ['required', 'numeric', 'min:0'], 'stock_quantity' => ['required', 'integer'], 'status' => ['required', 'in:draft,active,archived']]);
$data['slug'] = $data['slug'] ?: Str::slug($data['name']);
$data['currency'] = config('commerce.currency', 'GBP');
$data['published_at'] = $data['status'] === 'active' ? now() : null;
Product::create($data);
return back()->with('status', 'Product created.');
}
public function updateProduct(Request $request, Product $product): RedirectResponse
{
$product->update($request->validate(['name' => ['required', 'string', 'max:255'], 'price' => ['required', 'numeric', 'min:0'], 'stock_quantity' => ['required', 'integer'], 'status' => ['required', 'in:draft,active,archived']]));
return back()->with('status', 'Product updated.');
}
public function destroyProduct(Product $product): RedirectResponse
{
$product->delete();
return back()->with('status', 'Product deleted.');
}
public function updateOrder(Request $request, Order $order): RedirectResponse
{
$order->update($request->validate(['status' => ['required', 'in:pending,processing,completed,cancelled,refunded'], 'payment_status' => ['required', 'in:unpaid,authorised,paid,partially_refunded,refunded,failed'], 'fulfilment_status' => ['required', 'in:unfulfilled,partial,fulfilled,returned']]));
return back()->with('status', 'Order updated.');
}
public function updateProduct(Request $request, Product $product): RedirectResponse { $product->update($request->validate(['name'=>['required','string','max:255'],'price'=>['required','numeric','min:0'],'stock_quantity'=>['required','integer'],'status'=>['required','in:draft,active,archived']])); return back()->with('status','Product updated.'); }
public function destroyProduct(Product $product): RedirectResponse { $product->delete(); return back()->with('status','Product deleted.'); }
public function updateOrder(Request $request, Order $order): RedirectResponse { $order->update($request->validate(['status'=>['required','in:pending,processing,completed,cancelled,refunded'],'payment_status'=>['required','in:unpaid,authorised,paid,partially_refunded,refunded,failed'],'fulfilment_status'=>['required','in:unfulfilled,partial,fulfilled,returned']])); return back()->with('status','Order updated.'); }
}
@@ -1,10 +1,20 @@
<?php
namespace Modules\Commerce\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Modules\Commerce\Models\Product;
final class ProductApiController extends Controller
{
public function index(): JsonResponse { return response()->json(Product::query()->where('status','active')->where(fn($q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->latest()->paginate(24)); }
public function show(string $product): JsonResponse { return response()->json(Product::where('status','active')->where(fn($q)=>$q->where('slug',$product)->orWhere('id',$product))->firstOrFail()); }
public function index(): JsonResponse
{
return response()->json(Product::query()->where('status', 'active')->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->latest()->paginate(24));
}
public function show(string $product): JsonResponse
{
return response()->json(Product::where('status', 'active')->where(fn ($q) => $q->where('slug', $product)->orWhere('id', $product))->firstOrFail());
}
}
+9 -3
View File
@@ -1,11 +1,17 @@
<?php
namespace Modules\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
final class Category extends Model
{
protected $table = 'commerce_categories';
protected $guarded = [];
protected function casts(): array { return ['is_active'=>'boolean']; }
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
}
+9 -3
View File
@@ -1,11 +1,17 @@
<?php
namespace Modules\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
final class Customer extends Model
{
protected $table = 'commerce_customers';
protected $guarded = [];
protected function casts(): array { return ['billing_address'=>'array','shipping_address'=>'array']; }
protected function casts(): array
{
return ['billing_address' => 'array', 'shipping_address' => 'array'];
}
}
@@ -1,11 +1,17 @@
<?php
namespace Modules\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
final class InventoryMovement extends Model
{
protected $table = 'commerce_inventory_movements';
protected $guarded = [];
protected function casts(): array { return []; }
protected function casts(): array
{
return [];
}
}
+9 -3
View File
@@ -1,11 +1,17 @@
<?php
namespace Modules\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
final class Order extends Model
{
protected $table = 'commerce_orders';
protected $guarded = [];
protected function casts(): array { return ['subtotal'=>'decimal:2','discount_total'=>'decimal:2','shipping_total'=>'decimal:2','tax_total'=>'decimal:2','grand_total'=>'decimal:2','billing_address'=>'array','shipping_address'=>'array','placed_at'=>'datetime']; }
protected function casts(): array
{
return ['subtotal' => 'decimal:2', 'discount_total' => 'decimal:2', 'shipping_total' => 'decimal:2', 'tax_total' => 'decimal:2', 'grand_total' => 'decimal:2', 'billing_address' => 'array', 'shipping_address' => 'array', 'placed_at' => 'datetime'];
}
}
+9 -3
View File
@@ -1,11 +1,17 @@
<?php
namespace Modules\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
final class OrderItem extends Model
{
protected $table = 'commerce_order_items';
protected $guarded = [];
protected function casts(): array { return ['unit_price'=>'decimal:2','tax_total'=>'decimal:2','line_total'=>'decimal:2','metadata'=>'array']; }
protected function casts(): array
{
return ['unit_price' => 'decimal:2', 'tax_total' => 'decimal:2', 'line_total' => 'decimal:2', 'metadata' => 'array'];
}
}
+10 -1
View File
@@ -1,11 +1,20 @@
<?php
namespace Modules\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
final class Product extends Model
{
use SoftDeletes;
protected $table = 'commerce_products';
protected $guarded = [];
protected function casts(): array { return ['price'=>'decimal:2','compare_at_price'=>'decimal:2','cost_price'=>'decimal:2','track_inventory'=>'boolean','is_featured'=>'boolean','published_at'=>'datetime','metadata'=>'array']; }
protected function casts(): array
{
return ['price' => 'decimal:2', 'compare_at_price' => 'decimal:2', 'cost_price' => 'decimal:2', 'track_inventory' => 'boolean', 'is_featured' => 'boolean', 'published_at' => 'datetime', 'metadata' => 'array'];
}
}
+9 -3
View File
@@ -1,11 +1,17 @@
<?php
namespace Modules\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
final class ProductVariant extends Model
{
protected $table = 'commerce_product_variants';
protected $guarded = [];
protected function casts(): array { return ['price'=>'decimal:2','options'=>'array','is_active'=>'boolean']; }
protected function casts(): array
{
return ['price' => 'decimal:2', 'options' => 'array', 'is_active' => 'boolean'];
}
}
+9 -3
View File
@@ -1,11 +1,17 @@
<?php
namespace Modules\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
final class Promotion extends Model
{
protected $table = 'commerce_promotions';
protected $guarded = [];
protected function casts(): array { return ['value'=>'decimal:2','minimum_order'=>'decimal:2','starts_at'=>'datetime','ends_at'=>'datetime','is_active'=>'boolean','conditions'=>'array']; }
protected function casts(): array
{
return ['value' => 'decimal:2', 'minimum_order' => 'decimal:2', 'starts_at' => 'datetime', 'ends_at' => 'datetime', 'is_active' => 'boolean', 'conditions' => 'array'];
}
}
+1
View File
@@ -1,2 +1,3 @@
<?php
// Relationship reference: Product belongsTo Category and hasMany ProductVariant; Order hasMany OrderItem and belongsTo Customer.
@@ -1,14 +1,17 @@
<?php
namespace Modules\Commerce\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Commerce\Contracts\InventoryProvider;
use Modules\Commerce\Contracts\PaymentProvider;
use Modules\Commerce\Contracts\ShippingProvider;
use Modules\Commerce\Contracts\TaxProvider;
use Modules\Commerce\Services\DatabaseInventoryProvider;
use Modules\Commerce\Services\ManualPaymentProvider;
use Modules\Commerce\Services\FlatRateShippingProvider;
use Modules\Commerce\Services\ConfiguredTaxProvider;
use Modules\Commerce\Services\DatabaseInventoryProvider;
use Modules\Commerce\Services\FlatRateShippingProvider;
use Modules\Commerce\Services\ManualPaymentProvider;
final class CommerceServiceProvider extends ServiceProvider
{
public function register(): void
@@ -20,6 +23,7 @@ final class CommerceServiceProvider extends ServiceProvider
$this->app->bind(ShippingProvider::class, FlatRateShippingProvider::class);
$this->app->bind(TaxProvider::class, ConfiguredTaxProvider::class);
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Commerce', 'Database/Migrations'));
@@ -1,7 +1,10 @@
<?php
namespace Modules\Commerce\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
+4 -2
View File
@@ -1,5 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Commerce\Http\Controllers\ProductApiController;
Route::get('/commerce/products',[ProductApiController::class,'index']);
Route::get('/commerce/products/{product}',[ProductApiController::class,'show']);
Route::get('/commerce/products', [ProductApiController::class, 'index']);
Route::get('/commerce/products/{product}', [ProductApiController::class, 'show']);
+8 -6
View File
@@ -1,10 +1,12 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Commerce\Http\Controllers\CommerceController;
Route::prefix('administration/commerce')->name('administration.commerce.')->middleware(['auth','permission:commerce.view'])->group(function () {
Route::get('/',[CommerceController::class,'index'])->name('index');
Route::post('/products',[CommerceController::class,'storeProduct'])->middleware('permission:commerce.manage')->name('products.store');
Route::put('/products/{product}',[CommerceController::class,'updateProduct'])->middleware('permission:commerce.manage')->name('products.update');
Route::delete('/products/{product}',[CommerceController::class,'destroyProduct'])->middleware('permission:commerce.manage')->name('products.destroy');
Route::put('/orders/{order}',[CommerceController::class,'updateOrder'])->middleware('permission:commerce.manage')->name('orders.update');
Route::prefix('administration/commerce')->name('administration.commerce.')->middleware(['auth', 'permission:commerce.view'])->group(function () {
Route::get('/', [CommerceController::class, 'index'])->name('index');
Route::post('/products', [CommerceController::class, 'storeProduct'])->middleware('permission:commerce.manage')->name('products.store');
Route::put('/products/{product}', [CommerceController::class, 'updateProduct'])->middleware('permission:commerce.manage')->name('products.update');
Route::delete('/products/{product}', [CommerceController::class, 'destroyProduct'])->middleware('permission:commerce.manage')->name('products.destroy');
Route::put('/orders/{order}', [CommerceController::class, 'updateOrder'])->middleware('permission:commerce.manage')->name('orders.update');
});
@@ -1,4 +1,13 @@
<?php
namespace Modules\Commerce\Services;
use Modules\Commerce\Contracts\TaxProvider;
final class ConfiguredTaxProvider implements TaxProvider { public function calculate(float $subtotal,array $address=[]): float { return config('commerce.prices_include_tax') ? 0.0 : round($subtotal * 0.2,2); } }
final class ConfiguredTaxProvider implements TaxProvider
{
public function calculate(float $subtotal, array $address = []): float
{
return config('commerce.prices_include_tax') ? 0.0 : round($subtotal * 0.2, 2);
}
}
@@ -1,14 +1,24 @@
<?php
namespace Modules\Commerce\Services;
use Illuminate\Support\Facades\DB;
use Modules\Commerce\Contracts\InventoryProvider;
use Modules\Commerce\Models\InventoryMovement;
use Modules\Commerce\Models\Product;
final class DatabaseInventoryProvider implements InventoryProvider
{
public function adjust(Product $product, int $quantity, string $reason, ?int $userId = null): void
{
DB::transaction(function () use ($product,$quantity,$reason,$userId) { $product->increment('stock_quantity',$quantity); InventoryMovement::create(['product_id'=>$product->id,'quantity'=>$quantity,'reason'=>$reason,'created_by'=>$userId]); });
DB::transaction(function () use ($product, $quantity, $reason, $userId) {
$product->increment('stock_quantity', $quantity);
InventoryMovement::create(['product_id' => $product->id, 'quantity' => $quantity, 'reason' => $reason, 'created_by' => $userId]);
});
}
public function available(Product $product): int
{
return max(0, (int) $product->stock_quantity);
}
public function available(Product $product): int { return max(0,(int)$product->stock_quantity); }
}
@@ -1,4 +1,13 @@
<?php
namespace Modules\Commerce\Services;
use Modules\Commerce\Contracts\ShippingProvider;
final class FlatRateShippingProvider implements ShippingProvider { public function quote(array $address,float $subtotal): float { return $subtotal >= 50 ? 0.0 : 4.95; } }
final class FlatRateShippingProvider implements ShippingProvider
{
public function quote(array $address, float $subtotal): float
{
return $subtotal >= 50 ? 0.0 : 4.95;
}
}
@@ -1,6 +1,19 @@
<?php
namespace Modules\Commerce\Services;
use Modules\Commerce\Contracts\PaymentProvider;
use Modules\Commerce\Models\Order;
final class ManualPaymentProvider implements PaymentProvider
{ public function charge(Order $order,array $data=[]): array { return ['status'=>'pending','provider'=>'manual','reference'=>null]; } public function refund(Order $order,float $amount): array { return ['status'=>'pending','provider'=>'manual','amount'=>$amount]; } }
{
public function charge(Order $order, array $data = []): array
{
return ['status' => 'pending', 'provider' => 'manual', 'reference' => null];
}
public function refund(Order $order, float $amount): array
{
return ['status' => 'pending', 'provider' => 'manual', 'amount' => $amount];
}
}
@@ -1,8 +1,19 @@
<?php
namespace Modules\Commerce\Tests\Feature;
use Modules\Commerce\Contracts\InventoryProvider;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_commerce_routes_are_registered(): void { $this->assertTrue(app('router')->has('administration.commerce.index')); }
public function test_commerce_contracts_are_bound(): void { $this->assertInstanceOf(\Modules\Commerce\Contracts\InventoryProvider::class, app(\Modules\Commerce\Contracts\InventoryProvider::class)); }
public function test_commerce_routes_are_registered(): void
{
$this->assertTrue(app('router')->has('administration.commerce.index'));
}
public function test_commerce_contracts_are_bound(): void
{
$this->assertInstanceOf(InventoryProvider::class, app(InventoryProvider::class));
}
}