diff --git a/Modules/Commerce/Config/config.php b/Modules/Commerce/Config/config.php new file mode 100644 index 0000000..8570fe4 --- /dev/null +++ b/Modules/Commerce/Config/config.php @@ -0,0 +1,7 @@ + env('COMMERCE_CURRENCY', 'GBP'), + 'currency_symbol' => env('COMMERCE_CURRENCY_SYMBOL', '£'), + 'prices_include_tax' => (bool) env('COMMERCE_PRICES_INCLUDE_TAX', true), + 'low_stock_threshold' => (int) env('COMMERCE_LOW_STOCK_THRESHOLD', 5), +]; diff --git a/Modules/Commerce/Contracts/InventoryProvider.php b/Modules/Commerce/Contracts/InventoryProvider.php new file mode 100644 index 0000000..7b21e20 --- /dev/null +++ b/Modules/Commerce/Contracts/InventoryProvider.php @@ -0,0 +1,4 @@ +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); + } +}; diff --git a/Modules/Commerce/Http/Controllers/CommerceController.php b/Modules/Commerce/Http/Controllers/CommerceController.php new file mode 100644 index 0000000..6bc8014 --- /dev/null +++ b/Modules/Commerce/Http/Controllers/CommerceController.php @@ -0,0 +1,23 @@ +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.'); + } + 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.'); } +} diff --git a/Modules/Commerce/Http/Controllers/ProductApiController.php b/Modules/Commerce/Http/Controllers/ProductApiController.php new file mode 100644 index 0000000..e69a2ab --- /dev/null +++ b/Modules/Commerce/Http/Controllers/ProductApiController.php @@ -0,0 +1,10 @@ +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()); } +} diff --git a/Modules/Commerce/Models/Category.php b/Modules/Commerce/Models/Category.php new file mode 100644 index 0000000..db747f7 --- /dev/null +++ b/Modules/Commerce/Models/Category.php @@ -0,0 +1,11 @@ +'boolean']; } +} diff --git a/Modules/Commerce/Models/Customer.php b/Modules/Commerce/Models/Customer.php new file mode 100644 index 0000000..d02f30d --- /dev/null +++ b/Modules/Commerce/Models/Customer.php @@ -0,0 +1,11 @@ +'array','shipping_address'=>'array']; } +} diff --git a/Modules/Commerce/Models/InventoryMovement.php b/Modules/Commerce/Models/InventoryMovement.php new file mode 100644 index 0000000..899905f --- /dev/null +++ b/Modules/Commerce/Models/InventoryMovement.php @@ -0,0 +1,11 @@ +'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']; } +} diff --git a/Modules/Commerce/Models/OrderItem.php b/Modules/Commerce/Models/OrderItem.php new file mode 100644 index 0000000..44d6bdb --- /dev/null +++ b/Modules/Commerce/Models/OrderItem.php @@ -0,0 +1,11 @@ +'decimal:2','tax_total'=>'decimal:2','line_total'=>'decimal:2','metadata'=>'array']; } +} diff --git a/Modules/Commerce/Models/Product.php b/Modules/Commerce/Models/Product.php new file mode 100644 index 0000000..77933ba --- /dev/null +++ b/Modules/Commerce/Models/Product.php @@ -0,0 +1,11 @@ +'decimal:2','compare_at_price'=>'decimal:2','cost_price'=>'decimal:2','track_inventory'=>'boolean','is_featured'=>'boolean','published_at'=>'datetime','metadata'=>'array']; } +} diff --git a/Modules/Commerce/Models/ProductVariant.php b/Modules/Commerce/Models/ProductVariant.php new file mode 100644 index 0000000..ee0b549 --- /dev/null +++ b/Modules/Commerce/Models/ProductVariant.php @@ -0,0 +1,11 @@ +'decimal:2','options'=>'array','is_active'=>'boolean']; } +} diff --git a/Modules/Commerce/Models/Promotion.php b/Modules/Commerce/Models/Promotion.php new file mode 100644 index 0000000..6818b3d --- /dev/null +++ b/Modules/Commerce/Models/Promotion.php @@ -0,0 +1,11 @@ +'decimal:2','minimum_order'=>'decimal:2','starts_at'=>'datetime','ends_at'=>'datetime','is_active'=>'boolean','conditions'=>'array']; } +} diff --git a/Modules/Commerce/Models/Relations.php b/Modules/Commerce/Models/Relations.php new file mode 100644 index 0000000..58836df --- /dev/null +++ b/Modules/Commerce/Models/Relations.php @@ -0,0 +1,2 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Commerce', 'Config/config.php'), 'commerce'); + $this->app->bind(InventoryProvider::class, DatabaseInventoryProvider::class); + $this->app->bind(PaymentProvider::class, ManualPaymentProvider::class); + $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')); + $this->loadViewsFrom(module_path('Commerce', 'Resources/views'), 'commerce'); + } +} diff --git a/Modules/Commerce/Providers/RouteServiceProvider.php b/Modules/Commerce/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..1d0c2ec --- /dev/null +++ b/Modules/Commerce/Providers/RouteServiceProvider.php @@ -0,0 +1,12 @@ +group(module_path('Commerce', 'Routes/web.php')); + Route::prefix('api/v1')->middleware('api')->group(module_path('Commerce', 'Routes/api.php')); + } +} diff --git a/Modules/Commerce/Resources/views/admin/index.blade.php b/Modules/Commerce/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..4960512 --- /dev/null +++ b/Modules/Commerce/Resources/views/admin/index.blade.php @@ -0,0 +1,5 @@ +

Commerce

Catalogue, stock, customers, orders and promotions.

@if(session('status'))
{{ session('status') }}
@endif +
Products
{{ $products->total() }}
Orders
{{ \Modules\Commerce\Models\Order::count() }}
Revenue
{{ config('commerce.currency_symbol','£') }}{{ number_format($revenue,2) }}
Customers
{{ $customers }}
Low stock
{{ $lowStock }}
+
@csrf

Add product

Create product +
@forelse($products as $product)@empty@endforelse
ProductSKUPriceStockStatus
{{ $product->name }}{{ $product->sku ?: '—' }}{{ config('commerce.currency_symbol','£') }}{{ number_format($product->price,2) }}{{ $product->stock_quantity }}{{ ucfirst($product->status) }}
@csrf @method('DELETE')Delete
No products yet.
{{ $products->links() }} +

Recent orders

@forelse($orders as $order)@empty@endforelse
OrderCustomerTotalStatusPayment
{{ $order->number }}{{ $order->email }}{{ $order->currency }} {{ number_format($order->grand_total,2) }}{{ ucfirst($order->status) }}{{ ucfirst($order->payment_status) }}
No orders yet.
diff --git a/Modules/Commerce/Routes/api.php b/Modules/Commerce/Routes/api.php new file mode 100644 index 0000000..c2f8da8 --- /dev/null +++ b/Modules/Commerce/Routes/api.php @@ -0,0 +1,5 @@ +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'); +}); diff --git a/Modules/Commerce/Services/ConfiguredTaxProvider.php b/Modules/Commerce/Services/ConfiguredTaxProvider.php new file mode 100644 index 0000000..0952898 --- /dev/null +++ b/Modules/Commerce/Services/ConfiguredTaxProvider.php @@ -0,0 +1,4 @@ +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); } +} diff --git a/Modules/Commerce/Services/FlatRateShippingProvider.php b/Modules/Commerce/Services/FlatRateShippingProvider.php new file mode 100644 index 0000000..d7cf982 --- /dev/null +++ b/Modules/Commerce/Services/FlatRateShippingProvider.php @@ -0,0 +1,4 @@ += 50 ? 0.0 : 4.95; } } diff --git a/Modules/Commerce/Services/ManualPaymentProvider.php b/Modules/Commerce/Services/ManualPaymentProvider.php new file mode 100644 index 0000000..9e99055 --- /dev/null +++ b/Modules/Commerce/Services/ManualPaymentProvider.php @@ -0,0 +1,6 @@ +'pending','provider'=>'manual','reference'=>null]; } public function refund(Order $order,float $amount): array { return ['status'=>'pending','provider'=>'manual','amount'=>$amount]; } } diff --git a/Modules/Commerce/Tests/Feature/ModuleBootTest.php b/Modules/Commerce/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..63d503e --- /dev/null +++ b/Modules/Commerce/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,8 @@ +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)); } +} diff --git a/Modules/Commerce/module.json b/Modules/Commerce/module.json new file mode 100644 index 0000000..f5f48cb --- /dev/null +++ b/Modules/Commerce/module.json @@ -0,0 +1 @@ +{"name":"Commerce","alias":"commerce","version":"1.0.0","description":"Products, inventory, customers, orders, promotions, tax, shipping and payments.","keywords":["commerce","catalog","inventory","orders","customers"],"priority":110,"providers":["Modules\\Commerce\\Providers\\CommerceServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Media","Sites"]} diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php index 255c2d3..c7da01d 100644 --- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php +++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php @@ -63,6 +63,10 @@ final class RolePermissionSeeder extends Seeder 'api.manage', 'enterprise.view', 'enterprise.manage', + 'commerce.view', + 'commerce.manage', + 'commerce.orders.manage', + 'commerce.products.manage', ]; foreach ($permissions as $permission) { diff --git a/modules_statuses.json b/modules_statuses.json index 4f07025..0dafcf1 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -37,4 +37,6 @@ "Developer": true, "Api": true, "Enterprise": true +, + "Commerce": true } diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index 3afd230..c0a5e42 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -6,6 +6,7 @@ +{{ __('Commerce') }} {{ __('CRM') }} {{ __('Newsletter') }} {{ __('Events') }}