Repo rebuild

This commit is contained in:
Mark Gregory
2026-09-15 12:33:28 +01:00
commit be28c4feec
5629 changed files with 317074 additions and 0 deletions
@@ -0,0 +1,41 @@
<?php
namespace Modules\Platform\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Modules\Company\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\Company\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,63 @@
<?php
namespace Modules\Platform\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Schema;
use Modules\Company\Models\Company;
use Modules\Platform\app\Models\LicenseKey;
use Modules\Platform\app\Models\Payment;
use Modules\Platform\app\Models\PlatformPrice;
use Modules\Warehouse\app\Models\Warehouse;
class PlatformDashboardController extends Controller
{
public function __invoke(): View
{
$companies = Company::query()
->withCount(['users', 'warehouses', 'licenseKeys'])
->latest('id')
->limit(8)
->get();
$prices = PlatformPrice::query()
->when(Schema::hasColumn('platform_prices', 'is_active'), static function ($query): void {
$query->where('is_active', true);
})
->when(Schema::hasColumn('platform_prices', 'active'), static function ($query): void {
$query->where('active', true);
})
->orderBy('sort_order')
->orderBy('name')
->get();
return view('platform::dashboard.index', [
'companiesCount' => Company::query()->count(),
'activeCompaniesCount' => Company::query()->where('status', 'active')->count(),
'licensesCount' => LicenseKey::query()->count(),
'warehousesCount' => Warehouse::query()->count(),
'pricesCount' => PlatformPrice::query()->count(),
'paymentsTotal' => Payment::query()->where('status', 'paid')->sum('amount'),
'companies' => $companies,
'prices' => $prices,
'companyUsageChartLabels' => $companies->pluck('name')->values(),
'companyUsageChartSeries' => [
'Users' => $companies->pluck('users_count')->values(),
'Warehouses' => $companies->pluck('warehouses_count')->values(),
'Licences' => $companies->pluck('license_keys_count')->values(),
],
'platformMixChartLabels' => ['Companies', 'Warehouses', 'Licences'],
'platformMixChartValues' => [
Company::query()->count(),
Warehouse::query()->count(),
LicenseKey::query()->count(),
],
'companyStatusChartLabels' => ['Active', 'Inactive'],
'companyStatusChartValues' => [
Company::query()->where('status', 'active')->count(),
Company::query()->where('status', 'inactive')->count(),
],
]);
}
}
@@ -0,0 +1,130 @@
<?php
namespace Modules\Platform\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Modules\Platform\app\Models\PlatformPrice;
class PlatformPriceController extends Controller
{
/**
* Display the customer-facing platform pricing cards.
*/
public function publicIndex(): View
{
return view('platform::prices.public', [
'prices' => PlatformPrice::query()
->where('is_active', true)
->ordered()
->get(),
]);
}
/**
* Display platform prices in a card grid with administration actions.
*/
public function index(): View
{
return view('platform::prices.index', [
'prices' => PlatformPrice::ordered()->get(),
]);
}
/**
* Show the create price form.
*/
public function create(): View
{
return view('platform::prices.create', [
'price' => new PlatformPrice([
'currency' => 'gbp',
'sort_order' => 40,
'is_active' => true,
'features' => [],
]),
]);
}
/**
* Store a new platform price.
*/
public function store(Request $request): RedirectResponse
{
PlatformPrice::create($this->validatedPriceData($request));
return redirect()->route('platform.prices.index')->with('status', 'Price created.');
}
/**
* Show the edit price form.
*/
public function edit(PlatformPrice $price): View
{
return view('platform::prices.edit', [
'price' => $price,
]);
}
/**
* Update an existing platform price.
*/
public function update(Request $request, PlatformPrice $price): RedirectResponse
{
$price->update($this->validatedPriceData($request, $price));
return redirect()->route('platform.prices.index')->with('status', 'Price updated.');
}
/**
* Delete a platform price.
*/
public function destroy(PlatformPrice $price): RedirectResponse
{
$price->delete();
return redirect()->route('platform.prices.index')->with('status', 'Price deleted.');
}
/**
* Validate and normalise price request data.
*
* @return array<string, mixed>
*/
private function validatedPriceData(Request $request, ?PlatformPrice $price = null): array
{
$data = $request->validate([
'code' => ['nullable', 'string', 'max:40', 'unique:platform_prices,code,' . ($price?->id ?? 'NULL')],
'name' => ['required', 'string', 'max:255'],
'strapline' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'monthly_amount' => ['required', 'numeric', 'min:0', 'max:999999.99'],
'annual_amount' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
'currency' => ['required', 'string', 'size:3'],
'badge_label' => ['nullable', 'string', 'max:60'],
'features' => ['nullable', 'string', 'max:4000'],
'sort_order' => ['required', 'integer', 'min:0', 'max:65535'],
'is_featured' => ['nullable', 'boolean'],
'is_active' => ['nullable', 'boolean'],
]);
$data['code'] = Str::slug($data['code'] ?: $data['name']);
$data['monthly_amount'] = (int) round(((float) $data['monthly_amount']) * 100);
$data['annual_amount'] = $data['annual_amount'] === null || $data['annual_amount'] === ''
? null
: (int) round(((float) $data['annual_amount']) * 100);
$data['currency'] = strtolower($data['currency']);
$data['features'] = collect(preg_split('/\r\n|\r|\n/', (string) ($data['features'] ?? '')))
->map(fn (string $feature): string => trim($feature))
->filter()
->values()
->all();
$data['is_featured'] = $request->boolean('is_featured');
$data['is_active'] = $request->boolean('is_active');
return $data;
}
}
@@ -0,0 +1,42 @@
<?php
namespace Modules\Platform\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Modules\Company\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);
}
}
@@ -0,0 +1,48 @@
<?php
namespace Modules\Platform\app\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
use Modules\Company\Models\Company;
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;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Platform\app\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Company\Models\Company;
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);
}
}
@@ -0,0 +1,80 @@
<?php
namespace Modules\Platform\app\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class PlatformPrice extends Model
{
protected $fillable = [
'code',
'name',
'strapline',
'description',
'monthly_amount',
'annual_amount',
'currency',
'badge_label',
'features',
'sort_order',
'is_featured',
'is_active',
];
protected $casts = [
'features' => 'array',
'monthly_amount' => 'integer',
'annual_amount' => 'integer',
'sort_order' => 'integer',
'is_featured' => 'boolean',
'is_active' => 'boolean',
];
/**
* Scope prices into display order.
*/
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('sort_order')->orderBy('name');
}
/**
* Return the display currency symbol.
*/
public function currencySymbol(): string
{
return match (strtolower($this->currency)) {
'gbp' => '£',
'eur' => '€',
'usd' => '$',
default => strtoupper($this->currency) . ' ',
};
}
/**
* Format a stored minor-unit amount for display.
*/
public function formatAmount(?int $amount): string
{
if ($amount === null) {
return '—';
}
return $this->currencySymbol() . number_format($amount / 100, 2);
}
/**
* Return normalised feature rows.
*
* @return array<int, string>
*/
public function featureRows(): array
{
return collect($this->features ?? [])
->filter(fn ($feature): bool => is_string($feature) && trim($feature) !== '')
->map(fn (string $feature): string => trim($feature))
->values()
->all();
}
}