added full module support

This commit is contained in:
Mark Gregory
2026-06-10 12:42:07 +01:00
parent 12d4eec076
commit c59b16d791
63 changed files with 1639 additions and 131 deletions
@@ -0,0 +1,36 @@
<?php
namespace Modules\Platform\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Modules\Platform\app\Http\Middleware\EnsureLicensedTenant;
use Modules\Platform\app\Http\Middleware\EnsurePlatformAdmin;
use Modules\Platform\app\Http\Middleware\EnsureTenantAssigned;
class PlatformServiceProvider extends ServiceProvider
{
/**
* Register module services.
*/
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../config/platform.php', 'platform');
}
/**
* Bootstrap module services.
*/
public function boot(): void
{
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'platform');
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
Route::middleware('web')->group(__DIR__ . '/../routes/web.php');
app('router')->aliasMiddleware('tenant.assigned', EnsureTenantAssigned::class);
app('router')->aliasMiddleware('tenant.licensed', EnsureLicensedTenant::class);
app('router')->aliasMiddleware('platform.admin', EnsurePlatformAdmin::class);
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Platform\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Modules\Platform\app\Models\Company;
class CompanyController extends Controller
{
/**
* Display companies.
*/
public function index()
{
return view('platform::companies.index', [
'companies' => Company::withCount(['users', 'warehouses', 'licenseKeys'])->orderBy('name')->paginate(20),
]);
}
/**
* Create a company tenant.
*/
public function store(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'billing_email' => ['nullable', 'email', 'max:255'],
'status' => ['required', 'in:active,suspended'],
]);
Company::create([
...$data,
'slug' => Str::slug($data['name']),
'timezone' => 'Europe/London',
]);
return back()->with('status', 'Company created.');
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Platform\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Modules\Platform\app\Models\Company;
use Modules\Platform\app\Models\LicenseKey;
class LicenseKeyController extends Controller
{
/**
* Display licence keys.
*/
public function index()
{
return view('platform::licenses.index', [
'companies' => Company::orderBy('name')->get(),
'licenses' => LicenseKey::with('company')->latest()->paginate(20),
'plainKey' => session('plain_license_key'),
]);
}
/**
* Issue a new licence key.
*/
public function store(Request $request)
{
$data = $request->validate([
'company_id' => ['required', 'exists:companies,id'],
'label' => ['required', 'string', 'max:255'],
'plan' => ['required', 'in:standard,professional,enterprise'],
'max_users' => ['required', 'integer', 'min:1', 'max:10000'],
]);
$company = Company::findOrFail($data['company_id']);
$plainKey = LicenseKey::issueFor($company, $data['label'], $data['plan'], (int) $data['max_users']);
return back()->with('plain_license_key', $plainKey)->with('status', 'Licence key issued. Copy it now.');
}
}
@@ -0,0 +1,44 @@
<?php
namespace Modules\Platform\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Modules\Platform\app\Models\Company;
use Modules\Platform\app\Models\Payment;
class PaymentController extends Controller
{
/**
* Display company payments.
*/
public function index()
{
return view('platform::payments.index', [
'companies' => Company::orderBy('name')->get(),
'payments' => Payment::with('company')->latest()->paginate(20),
]);
}
/**
* Add a manual payment or admin override record.
*/
public function store(Request $request)
{
$data = $request->validate([
'company_id' => ['required', 'exists:companies,id'],
'amount' => ['required', 'integer', 'min:0'],
'currency' => ['required', 'string', 'size:3'],
'status' => ['required', 'in:pending,paid,failed,refunded,override'],
'description' => ['nullable', 'string', 'max:255'],
]);
Payment::create([
...$data,
'paid_at' => in_array($data['status'], ['paid', 'override'], true) ? now() : null,
'metadata' => ['source' => 'manual_admin'],
]);
return back()->with('status', 'Payment record created.');
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Platform\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Modules\Platform\app\Models\Company;
use Modules\Platform\app\Models\LicenseKey;
use Modules\Platform\app\Models\Payment;
use Modules\Warehouse\app\Models\Warehouse;
class PlatformDashboardController extends Controller
{
/**
* Show the platform administration dashboard.
*/
public function __invoke()
{
return view('platform::dashboard.index', [
'companiesCount' => Company::count(),
'activeCompaniesCount' => Company::where('status', 'active')->count(),
'licensesCount' => LicenseKey::count(),
'paymentsTotal' => Payment::where('status', 'paid')->sum('amount'),
'warehousesCount' => Warehouse::count(),
'companies' => Company::withCount(['users', 'warehouses', 'licenseKeys'])->latest()->limit(8)->get(),
]);
}
}
@@ -0,0 +1,42 @@
<?php
namespace Modules\Platform\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Modules\Platform\app\Models\Company;
use Modules\Platform\app\Models\Payment;
class StripeWebhookController extends Controller
{
/**
* Receive a Stripe webhook event.
*/
public function __invoke(Request $request)
{
$payload = $request->all();
$eventType = data_get($payload, 'type');
if ($eventType === 'payment_intent.succeeded') {
$intent = data_get($payload, 'data.object', []);
$company = Company::where('stripe_customer_id', data_get($intent, 'customer'))->first();
if ($company) {
Payment::updateOrCreate(
['stripe_payment_intent_id' => data_get($intent, 'id')],
[
'company_id' => $company->id,
'amount' => (int) data_get($intent, 'amount_received', 0),
'currency' => (string) data_get($intent, 'currency', 'gbp'),
'status' => 'paid',
'description' => 'Stripe payment received',
'paid_at' => now(),
'metadata' => $intent,
]
);
}
}
return response()->json(['received' => true]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Platform\app\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureLicensedTenant
{
/**
* Ensure the current company has an active licence unless it is the MPBA platform tenant.
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$company = $user?->company;
abort_if(! $company, 403, 'No company is assigned to this account.');
if ($user->isMpbaPlatformAdmin()) {
return $next($request);
}
abort_unless($company->hasActiveLicense(), 402, 'Payment Required');
return $next($request);
}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Platform\app\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsurePlatformAdmin
{
/**
* Ensure the current user is an MPBA platform administrator.
*/
public function handle(Request $request, Closure $next): Response
{
abort_unless($request->user()?->isMpbaPlatformAdmin(), 403);
return $next($request);
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Platform\app\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureTenantAssigned
{
/**
* Ensure the authenticated user belongs to a company tenant.
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
abort_if(! $user || ! $user->company_id, 403, 'No company is assigned to this account.');
return $next($request);
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace Modules\Platform\app\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Modules\Warehouse\app\Models\Warehouse;
class Company extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'uuid', 'name', 'slug', 'status', 'timezone', 'settings',
'is_system_company', 'stripe_customer_id', 'billing_email',
];
protected $casts = [
'settings' => 'array',
'is_system_company' => 'boolean',
];
protected static function booted(): void
{
static::creating(function (Company $company): void {
$company->uuid ??= (string) Str::uuid();
$company->slug ??= Str::slug($company->name);
$company->status ??= 'active';
$company->timezone ??= 'Europe/London';
});
}
public function users(): HasMany
{
return $this->hasMany(\App\Models\User::class);
}
public function licenseKeys(): HasMany
{
return $this->hasMany(LicenseKey::class);
}
public function payments(): HasMany
{
return $this->hasMany(Payment::class);
}
public function warehouses(): HasMany
{
return $this->hasMany(Warehouse::class);
}
public function hasActiveLicense(): bool
{
if ($this->is_system_company) {
return true;
}
return $this->licenseKeys()
->where('is_active', true)
->where(function ($query): void {
$query->whereNull('starts_at')->orWhere('starts_at', '<=', now());
})
->where(function ($query): void {
$query->whereNull('expires_at')->orWhere('expires_at', '>=', now());
})
->whereNull('revoked_at')
->exists();
}
}
@@ -0,0 +1,47 @@
<?php
namespace Modules\Platform\app\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class LicenseKey extends Model
{
protected $fillable = [
'company_id', 'key_hash', 'key_prefix', 'label', 'plan', 'max_users',
'is_active', 'starts_at', 'expires_at', 'last_used_at', 'revoked_at',
];
protected $casts = [
'is_active' => 'boolean',
'starts_at' => 'datetime',
'expires_at' => 'datetime',
'last_used_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function company(): BelongsTo
{
return $this->belongsTo(Company::class);
}
public static function issueFor(Company $company, string $label, string $plan = 'standard', int $maxUsers = 10): string
{
$plainKey = 'WMS-' . Str::upper(Str::random(32));
self::create([
'company_id' => $company->id,
'key_hash' => hash('sha256', $plainKey),
'key_prefix' => Str::substr($plainKey, 0, 12),
'label' => $label,
'plan' => $plan,
'max_users' => $maxUsers,
'is_active' => true,
'starts_at' => now(),
'expires_at' => now()->addYear(),
]);
return $plainKey;
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Platform\app\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Payment extends Model
{
protected $fillable = [
'company_id', 'stripe_payment_intent_id', 'stripe_invoice_id', 'amount',
'currency', 'status', 'description', 'paid_at', 'metadata',
];
protected $casts = [
'amount' => 'integer',
'paid_at' => 'datetime',
'metadata' => 'array',
];
public function company(): BelongsTo
{
return $this->belongsTo(Company::class);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
return [
'system_company_slug' => env('WMS_SYSTEM_COMPANY_SLUG', 'mpba-limited'),
'system_admin_email' => env('WMS_SYSTEM_ADMIN_EMAIL', 'mark.gregory@mpba.co.uk'),
'internal_api_key' => env('WMS_INTERNAL_API_KEY', 'local-dev-change-me'),
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
];
@@ -0,0 +1,80 @@
<?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
{
Schema::create('companies', function (Blueprint $table): void {
$table->id();
$table->uuid('uuid')->unique();
$table->string('name');
$table->string('slug')->unique();
$table->string('status')->default('active')->index();
$table->string('timezone')->default('Europe/London');
$table->boolean('is_system_company')->default(false)->index();
$table->string('stripe_customer_id')->nullable()->index();
$table->string('billing_email')->nullable();
$table->json('settings')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::table('users', function (Blueprint $table): void {
$table->foreignId('company_id')->nullable()->after('id')->constrained('companies')->nullOnDelete();
$table->boolean('is_platform_admin')->default(false)->after('password');
$table->boolean('is_company_admin')->default(false)->after('is_platform_admin');
$table->string('status')->default('active')->after('is_company_admin')->index();
});
Schema::create('license_keys', function (Blueprint $table): void {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->string('key_hash', 64)->unique();
$table->string('key_prefix', 16)->index();
$table->string('label');
$table->string('plan')->default('standard')->index();
$table->unsignedInteger('max_users')->default(10);
$table->boolean('is_active')->default(true)->index();
$table->timestamp('starts_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('revoked_at')->nullable()->index();
$table->timestamps();
});
Schema::create('payments', function (Blueprint $table): void {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->string('stripe_payment_intent_id')->nullable()->unique();
$table->string('stripe_invoice_id')->nullable()->index();
$table->unsignedInteger('amount')->default(0);
$table->string('currency', 3)->default('gbp');
$table->string('status')->default('pending')->index();
$table->string('description')->nullable();
$table->timestamp('paid_at')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('payments');
Schema::dropIfExists('license_keys');
Schema::table('users', function (Blueprint $table): void {
$table->dropConstrainedForeignId('company_id');
$table->dropColumn(['is_platform_admin', 'is_company_admin', 'status']);
});
Schema::dropIfExists('companies');
}
};
@@ -0,0 +1,55 @@
<?php
namespace Modules\Platform\database\seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Modules\Platform\app\Models\Company;
use Modules\Warehouse\app\Models\Warehouse;
use Modules\Warehouse\app\Models\WarehouseValidationRule;
class PlatformSeeder extends Seeder
{
/**
* Seed the MPBA system tenant and administrator.
*/
public function run(): void
{
$company = Company::updateOrCreate(
['slug' => 'mpba-limited'],
[
'uuid' => (string) Str::uuid(),
'name' => 'MPBA Limited',
'status' => 'active',
'timezone' => 'Europe/London',
'is_system_company' => true,
'billing_email' => 'mark.gregory@mpba.co.uk',
]
);
User::updateOrCreate(
['email' => 'mark.gregory@mpba.co.uk'],
[
'company_id' => $company->id,
'name' => 'Mark Gregory',
'password' => Hash::make('987400Kmh'),
'is_platform_admin' => true,
'is_company_admin' => true,
'status' => 'active',
'email_verified_at' => now(),
]
);
$warehouse = Warehouse::updateOrCreate(
['company_id' => $company->id, 'code' => 'MPBA-MAIN'],
['name' => 'MPBA Main Warehouse', 'status' => 'active', 'timezone' => 'Europe/London']
);
WarehouseValidationRule::updateOrCreate(
['warehouse_id' => $warehouse->id, 'rule_key' => 'stock.location.required'],
['name' => 'Stock location required', 'severity' => 'error', 'is_active' => true, 'configuration' => ['required' => true]]
);
}
}
+1
View File
@@ -0,0 +1 @@
{"name":"Platform","alias":"platform","description":"System administration, tenants, licences, Stripe payments and tenant guards.","keywords":["wms","multi-tenant","licensing"],"priority":0,"providers":["Modules\\Platform\\Providers\\PlatformServiceProvider"],"files":[]}
@@ -0,0 +1,21 @@
<x-layouts::app :title="__('Companies')">
<div class="space-y-6">
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
<h1 class="text-2xl font-bold">Companies</h1>
<form method="POST" action="{{ route('platform.companies.store') }}" class="mt-5 grid gap-4 md:grid-cols-4">
@csrf
<input name="name" placeholder="Company name" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
<input name="billing_email" placeholder="billing@example.com" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
<select name="status" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"><option value="active">Active</option><option value="suspended">Suspended</option></select>
<button class="rounded-xl bg-zinc-900 px-4 py-2 font-semibold text-white dark:bg-white dark:text-zinc-900">Create</button>
</form>
</div>
<div class="overflow-x-auto rounded-2xl bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
<table class="min-w-full text-sm">
<thead><tr class="border-b"><th class="p-4 text-left">Name</th><th class="p-4 text-left">Users</th><th class="p-4 text-left">Warehouses</th><th class="p-4 text-left">Licences</th><th class="p-4 text-left">Status</th></tr></thead>
<tbody>@foreach($companies as $company)<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="p-4 font-semibold">{{ $company->name }}</td><td class="p-4">{{ $company->users_count }}</td><td class="p-4">{{ $company->warehouses_count }}</td><td class="p-4">{{ $company->license_keys_count }}</td><td class="p-4">{{ $company->is_system_company ? 'System' : $company->status }}</td></tr>@endforeach</tbody>
</table>
</div>
{{ $companies->links() }}
</div>
</x-layouts::app>
@@ -0,0 +1,60 @@
<x-layouts::app :title="__('Platform Dashboard')">
<div class="space-y-6">
<section class="overflow-hidden rounded-3xl bg-zinc-950 p-6 text-white shadow-xl sm:p-8">
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-[0.3em] text-cyan-300">MPBA WMS</p>
<h1 class="mt-3 text-3xl font-bold tracking-tight sm:text-5xl">Platform administration</h1>
<p class="mt-3 max-w-2xl text-zinc-300">Manage tenant companies, licences, payments, warehouses and internal API operations from one resilient control plane.</p>
</div>
<div class="rounded-2xl bg-white/10 p-5 backdrop-blur">
<p class="text-sm text-zinc-300">System status</p>
<p class="mt-2 text-2xl font-bold text-emerald-300">Operational</p>
</div>
</div>
</section>
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
@foreach ([['Companies', $companiesCount], ['Active', $activeCompaniesCount], ['Warehouses', $warehousesCount], ['Licences', $licensesCount], ['Paid £', number_format($paymentsTotal / 100, 2)]] as [$label, $value])
<article class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ $label }}</p>
<p class="mt-2 text-3xl font-bold text-zinc-900 dark:text-white">{{ $value }}</p>
</article>
@endforeach
</section>
<section class="rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div class="flex flex-col gap-3 border-b border-zinc-200 p-5 sm:flex-row sm:items-center sm:justify-between dark:border-zinc-800">
<div>
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Recent companies</h2>
<p class="text-sm text-zinc-500">Tenant isolation is enforced by company ownership on every warehouse record.</p>
</div>
<a href="{{ route('platform.companies.index') }}" class="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-semibold text-white dark:bg-white dark:text-zinc-900">Manage companies</a>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-800">
<thead class="bg-zinc-50 dark:bg-zinc-950">
<tr>
<th class="px-5 py-3 text-left font-semibold">Company</th>
<th class="px-5 py-3 text-left font-semibold">Users</th>
<th class="px-5 py-3 text-left font-semibold">Warehouses</th>
<th class="px-5 py-3 text-left font-semibold">Licences</th>
<th class="px-5 py-3 text-left font-semibold">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-800">
@foreach ($companies as $company)
<tr>
<td class="px-5 py-4 font-medium">{{ $company->name }}</td>
<td class="px-5 py-4">{{ $company->users_count }}</td>
<td class="px-5 py-4">{{ $company->warehouses_count }}</td>
<td class="px-5 py-4">{{ $company->license_keys_count }}</td>
<td class="px-5 py-4">{{ $company->is_system_company ? 'System' : ucfirst($company->status) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</section>
</div>
</x-layouts::app>
@@ -0,0 +1,18 @@
<x-layouts::app :title="__('Licence Keys')">
<div class="space-y-6">
@if($plainKey)<div class="rounded-2xl bg-emerald-50 p-4 text-emerald-900 ring-1 ring-emerald-200">New key: <code class="font-bold">{{ $plainKey }}</code></div>@endif
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
<h1 class="text-2xl font-bold">Licence keys</h1>
<form method="POST" action="{{ route('platform.licenses.store') }}" class="mt-5 grid gap-4 md:grid-cols-5">
@csrf
<select name="company_id" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">@foreach($companies as $company)<option value="{{ $company->id }}">{{ $company->name }}</option>@endforeach</select>
<input name="label" placeholder="Licence label" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
<select name="plan" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"><option>standard</option><option>professional</option><option>enterprise</option></select>
<input name="max_users" type="number" value="10" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
<button class="rounded-xl bg-zinc-900 px-4 py-2 font-semibold text-white dark:bg-white dark:text-zinc-900">Issue</button>
</form>
</div>
<div class="overflow-x-auto rounded-2xl bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><table class="min-w-full text-sm"><thead><tr class="border-b"><th class="p-4 text-left">Company</th><th class="p-4 text-left">Label</th><th class="p-4 text-left">Plan</th><th class="p-4 text-left">Prefix</th><th class="p-4 text-left">Expires</th></tr></thead><tbody>@foreach($licenses as $license)<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="p-4">{{ $license->company->name }}</td><td class="p-4 font-semibold">{{ $license->label }}</td><td class="p-4">{{ $license->plan }}</td><td class="p-4"><code>{{ $license->key_prefix }}</code></td><td class="p-4">{{ optional($license->expires_at)->toDateString() }}</td></tr>@endforeach</tbody></table></div>
{{ $licenses->links() }}
</div>
</x-layouts::app>
@@ -0,0 +1,7 @@
@props(['title' => 'WMS Platform'])
<div class="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<div class="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8">
{{ $slot }}
</div>
</div>
@@ -0,0 +1,18 @@
<x-layouts::app :title="__('Payments')">
<div class="space-y-6">
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
<h1 class="text-2xl font-bold">Payments and overrides</h1>
<form method="POST" action="{{ route('platform.payments.store') }}" class="mt-5 grid gap-4 md:grid-cols-6">
@csrf
<select name="company_id" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">@foreach($companies as $company)<option value="{{ $company->id }}">{{ $company->name }}</option>@endforeach</select>
<input name="amount" type="number" value="0" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
<input name="currency" value="gbp" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
<select name="status" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"><option>paid</option><option>pending</option><option>failed</option><option>refunded</option><option>override</option></select>
<input name="description" placeholder="Description" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
<button class="rounded-xl bg-zinc-900 px-4 py-2 font-semibold text-white dark:bg-white dark:text-zinc-900">Add</button>
</form>
</div>
<div class="overflow-x-auto rounded-2xl bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><table class="min-w-full text-sm"><thead><tr class="border-b"><th class="p-4 text-left">Company</th><th class="p-4 text-left">Amount</th><th class="p-4 text-left">Status</th><th class="p-4 text-left">Description</th></tr></thead><tbody>@foreach($payments as $payment)<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="p-4">{{ $payment->company->name }}</td><td class="p-4 font-semibold">{{ strtoupper($payment->currency) }} {{ number_format($payment->amount / 100, 2) }}</td><td class="p-4">{{ $payment->status }}</td><td class="p-4">{{ $payment->description }}</td></tr>@endforeach</tbody></table></div>
{{ $payments->links() }}
</div>
</x-layouts::app>
+23
View File
@@ -0,0 +1,23 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Platform\app\Http\Controllers\CompanyController;
use Modules\Platform\app\Http\Controllers\LicenseKeyController;
use Modules\Platform\app\Http\Controllers\PaymentController;
use Modules\Platform\app\Http\Controllers\PlatformDashboardController;
use Modules\Platform\app\Http\Controllers\StripeWebhookController;
Route::middleware(['auth', 'verified', 'tenant.assigned', 'platform.admin'])
->prefix('admin')
->name('platform.')
->group(function (): void {
Route::get('/', PlatformDashboardController::class)->name('dashboard');
Route::get('/companies', [CompanyController::class, 'index'])->name('companies.index');
Route::post('/companies', [CompanyController::class, 'store'])->name('companies.store');
Route::get('/licenses', [LicenseKeyController::class, 'index'])->name('licenses.index');
Route::post('/licenses', [LicenseKeyController::class, 'store'])->name('licenses.store');
Route::get('/payments', [PaymentController::class, 'index'])->name('payments.index');
Route::post('/payments', [PaymentController::class, 'store'])->name('payments.store');
});
Route::post('/stripe/webhook', StripeWebhookController::class)->name('stripe.webhook');
@@ -0,0 +1,26 @@
<?php
namespace Modules\Platform\tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Platform\app\Models\Company;
use Modules\Platform\app\Models\LicenseKey;
use Tests\TestCase;
class LicenseKeyTest extends TestCase
{
use RefreshDatabase;
public function test_license_key_is_hashed_and_not_stored_plain(): void
{
$company = Company::create(['name' => 'Tenant A', 'slug' => 'tenant-a']);
$plainKey = LicenseKey::issueFor($company, 'Production licence', 'enterprise', 50);
$license = LicenseKey::firstOrFail();
$this->assertStringStartsWith('WMS-', $plainKey);
$this->assertNotSame($plainKey, $license->key_hash);
$this->assertSame(hash('sha256', $plainKey), $license->key_hash);
$this->assertTrue($company->fresh()->hasActiveLicense());
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Platform\tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Platform\app\Models\Company;
use Tests\TestCase;
class PlatformAccessTest extends TestCase
{
use RefreshDatabase;
public function test_mpba_platform_admin_can_access_admin_dashboard_without_licence(): void
{
$company = Company::create([
'name' => 'MPBA Limited',
'slug' => 'mpba-limited',
'is_system_company' => true,
'status' => 'active',
]);
$user = User::factory()->create([
'company_id' => $company->id,
'email' => 'mark.gregory@mpba.co.uk',
'is_platform_admin' => true,
'is_company_admin' => true,
]);
$this->actingAs($user)
->get('/admin')
->assertOk()
->assertSee('Platform administration');
}
public function test_non_system_company_requires_license(): void
{
$company = Company::create([
'name' => 'Customer Limited',
'slug' => 'customer-limited',
'status' => 'active',
]);
$user = User::factory()->create(['company_id' => $company->id]);
$this->actingAs($user)
->get('/dashboard')
->assertStatus(402);
}
}