diff --git a/README.md b/README.md index 9b2dcbb..5b4626f 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ In-store register at **pos.ladill.com** — product grid, cart, cash or Ladill Pay checkout. -## Features (v1) +## Features - **Register** — catalog + quick-amount sales - **Products** — local catalog CRUD - **Sales** — history and receipt view -- **Ladill Pay** — MoMo/card via `source_service: pos`, `fee_tier: sales` +- **Devices** — tills, customer displays, kitchen screens, printers (tokenized where needed) +- **Payment gateway** — merchant Paystack / Flutterwave / Hubtel (Pro+) ## Local dev diff --git a/app/Console/Commands/MarkDevicesOfflineCommand.php b/app/Console/Commands/MarkDevicesOfflineCommand.php new file mode 100644 index 0000000..1a77416 --- /dev/null +++ b/app/Console/Commands/MarkDevicesOfflineCommand.php @@ -0,0 +1,22 @@ +option('minutes')); + $count = $devices->markStaleDevicesOffline($minutes); + $this->info("Marked {$count} device(s) offline."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Pos/DeviceController.php b/app/Http/Controllers/Pos/DeviceController.php new file mode 100644 index 0000000..9deceab --- /dev/null +++ b/app/Http/Controllers/Pos/DeviceController.php @@ -0,0 +1,172 @@ +ownerRef($request); + + $query = PosDevice::owned($owner)->with('location')->orderBy('name'); + $this->scopeToLocation($request, $query); + + $devices = $query->paginate(25); + + $statsQuery = PosDevice::owned($owner); + $this->scopeToLocation($request, $statsQuery); + + $heroStats = [ + 'total' => (clone $statsQuery)->count(), + 'online' => (clone $statsQuery) + ->where('status', PosDevice::STATUS_ONLINE) + ->where('last_online_at', '>=', now()->subMinutes(10)) + ->count(), + 'displays' => (clone $statsQuery) + ->whereIn('type', ['customer_display', 'kitchen_display']) + ->count(), + ]; + + return view('pos.devices.index', [ + 'devices' => $devices, + 'deviceTypes' => config('pos.device_types', []), + 'heroStats' => $heroStats, + ]); + } + + public function create(Request $request): View + { + return view('pos.devices.create', [ + 'deviceTypes' => config('pos.device_types', []), + 'hints' => config('pos.device_registration_hints', []), + 'locations' => $this->locations($request), + 'defaultLocationId' => $this->location($request)->id, + ]); + } + + public function store(Request $request): RedirectResponse + { + $types = array_keys(config('pos.device_types', [])); + $owner = $this->ownerRef($request); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'string', Rule::in($types)], + 'location_id' => ['nullable', 'integer'], + ]); + + $locationId = $validated['location_id'] ?? null; + if ($locationId) { + $this->assertLocationOwned($request, (int) $locationId); + } + + if (in_array($validated['type'], ['customer_display', 'kitchen_display'], true) && ! $locationId) { + return back()->withInput()->with('error', 'Choose a branch for this display device.'); + } + + $this->devices->register([ + 'owner_ref' => $owner, + 'name' => $validated['name'], + 'type' => $validated['type'], + 'location_id' => $locationId, + ]); + + return redirect()->route('pos.devices.index')->with('success', 'Device registered.'); + } + + public function edit(Request $request, PosDevice $device): View + { + $this->authorizeOwner($request, $device); + + return view('pos.devices.edit', [ + 'device' => $device, + 'deviceTypes' => config('pos.device_types', []), + 'hints' => config('pos.device_registration_hints', []), + 'locations' => $this->locations($request), + 'openUrl' => $this->devices->openUrl($device), + ]); + } + + public function update(Request $request, PosDevice $device): RedirectResponse + { + $this->authorizeOwner($request, $device); + + $types = array_keys(config('pos.device_types', [])); + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'string', Rule::in($types)], + 'location_id' => ['nullable', 'integer'], + 'status' => ['nullable', 'string', Rule::in([ + PosDevice::STATUS_ONLINE, + PosDevice::STATUS_OFFLINE, + PosDevice::STATUS_MAINTENANCE, + ])], + ]); + + if (! empty($validated['location_id'])) { + $this->assertLocationOwned($request, (int) $validated['location_id']); + } + + $device->update([ + 'name' => $validated['name'], + 'type' => $validated['type'], + 'location_id' => $validated['location_id'] ?: null, + 'status' => $validated['status'] ?? $device->status, + ]); + + return redirect()->route('pos.devices.index')->with('success', 'Device updated.'); + } + + public function destroy(Request $request, PosDevice $device): RedirectResponse + { + $this->authorizeOwner($request, $device); + $device->delete(); + + return redirect()->route('pos.devices.index')->with('success', 'Device removed.'); + } + + public function regenerateToken(Request $request, PosDevice $device): RedirectResponse + { + $this->authorizeOwner($request, $device); + $this->devices->regenerateToken($device); + + return back()->with('success', 'Device token regenerated. Re-open the device URL if needed.'); + } + + /** @return \Illuminate\Support\Collection */ + protected function locations(Request $request) + { + $query = PosLocation::owned($this->ownerRef($request))->orderBy('name'); + $scope = $this->locationScope($request); + if ($scope !== null) { + $query->whereKey($scope); + } + + return $query->get(); + } + + protected function assertLocationOwned(Request $request, int $locationId): void + { + $exists = PosLocation::owned($this->ownerRef($request))->whereKey($locationId)->exists(); + abort_unless($exists, 404); + + $scope = $this->locationScope($request); + if ($scope !== null) { + abort_unless($locationId === $scope, 404); + } + } +} diff --git a/app/Http/Controllers/Public/CustomerDisplayController.php b/app/Http/Controllers/Public/CustomerDisplayController.php index 3aa5b69..8a592d7 100644 --- a/app/Http/Controllers/Public/CustomerDisplayController.php +++ b/app/Http/Controllers/Public/CustomerDisplayController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Models\PosCustomerDisplay; use App\Models\PosSale; use App\Services\Pos\CustomerDisplayService; +use App\Services\Pos\DeviceService; use App\Services\Pos\ReceiptEmailService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -18,11 +19,13 @@ class CustomerDisplayController extends Controller public function __construct( private CustomerDisplayService $displays, private ReceiptEmailService $receiptEmail, + private DeviceService $devices, ) {} public function show(string $token): View { $display = $this->find($token); + $this->devices->touchByToken($token); return view('pos.customer-display', [ 'display' => $display, @@ -36,6 +39,7 @@ class CustomerDisplayController extends Controller public function state(string $token): JsonResponse { $display = $this->find($token); + $this->devices->touchByToken($token); return response()->json($this->displays->publicState($display)); } diff --git a/app/Models/PosDevice.php b/app/Models/PosDevice.php new file mode 100644 index 0000000..4814565 --- /dev/null +++ b/app/Models/PosDevice.php @@ -0,0 +1,62 @@ + 'array', + 'last_online_at' => 'datetime', + ]; + } + + public function scopeOwned(Builder $query, string $ownerRef): Builder + { + return $query->where('owner_ref', $ownerRef); + } + + public function location(): BelongsTo + { + return $this->belongsTo(PosLocation::class, 'location_id'); + } + + public function isOnline(int $staleMinutes = 10): bool + { + return $this->status === self::STATUS_ONLINE + && $this->last_online_at + && $this->last_online_at->gte(now()->subMinutes($staleMinutes)); + } + + public function typeLabel(): string + { + return (string) (config('pos.device_types.'.$this->type) ?? $this->type); + } + + public function usesToken(): bool + { + return in_array($this->type, config('pos.device_token_types', []), true); + } +} diff --git a/app/Services/Pos/DeviceService.php b/app/Services/Pos/DeviceService.php new file mode 100644 index 0000000..e7f5d01 --- /dev/null +++ b/app/Services/Pos/DeviceService.php @@ -0,0 +1,148 @@ +where('device_token', $token)->first(); + } + + public function recordHeartbeat(PosDevice $device): PosDevice + { + $device->update([ + 'status' => PosDevice::STATUS_ONLINE, + 'last_online_at' => now(), + ]); + + return $device->fresh(); + } + + /** Mark token-bearing devices online when their public endpoints are hit. */ + public function touchByToken(?string $token): void + { + if (! $token) { + return; + } + + $device = $this->findByToken($token); + if ($device) { + $this->recordHeartbeat($device); + } + } + + public function markStaleDevicesOffline(int $minutes = 10): int + { + return PosDevice::query() + ->where('status', PosDevice::STATUS_ONLINE) + ->where(function ($q) use ($minutes) { + $q->whereNull('last_online_at') + ->orWhere('last_online_at', '<', now()->subMinutes($minutes)); + }) + ->update(['status' => PosDevice::STATUS_OFFLINE]); + } + + /** @return array */ + public function defaultConfigForType(string $type): array + { + return match ($type) { + 'customer_display' => ['role' => 'customer_facing'], + 'kitchen_display' => ['role' => 'kitchen'], + 'receipt_printer' => ['paper_mm' => 80, 'driver' => 'browser'], + 'register' => ['role' => 'till'], + default => [], + }; + } + + /** + * Create a device; customer_display types share the branch display token. + * + * @param array{name: string, type: string, location_id?: ?int, owner_ref: string} $data + */ + public function register(array $data): PosDevice + { + $type = $data['type']; + $locationId = isset($data['location_id']) ? (int) $data['location_id'] : null; + $token = null; + + if (in_array($type, config('pos.device_token_types', []), true)) { + $token = $this->tokenForNewDevice($type, $locationId, $data['owner_ref']); + } + + return PosDevice::create([ + 'owner_ref' => $data['owner_ref'], + 'location_id' => $locationId ?: null, + 'name' => $data['name'], + 'type' => $type, + 'status' => PosDevice::STATUS_OFFLINE, + 'device_token' => $token, + 'config' => $this->defaultConfigForType($type), + ]); + } + + public function regenerateToken(PosDevice $device): PosDevice + { + if (! $device->usesToken()) { + return $device; + } + + $token = $this->generateToken(); + + if ($device->type === 'customer_display' && $device->location_id) { + $location = PosLocation::query()->find($device->location_id); + if ($location) { + $display = $this->displays->ensureForLocation($location); + $display->update(['token' => $token]); + } + } + + $device->update(['device_token' => $token]); + + return $device->fresh(); + } + + public function openUrl(PosDevice $device): ?string + { + if (! $device->device_token) { + return null; + } + + return match ($device->type) { + 'customer_display' => route('pos.customer-display.show', ['token' => $device->device_token]), + 'kitchen_display' => route('pos.kitchen'), + default => null, + }; + } + + private function tokenForNewDevice(string $type, ?int $locationId, string $ownerRef): string + { + if ($type === 'customer_display' && $locationId) { + $location = PosLocation::query() + ->where('owner_ref', $ownerRef) + ->whereKey($locationId) + ->first(); + + if ($location) { + $display = $this->displays->ensureForLocation($location); + + // Keep a single active display token per branch; reuse it for the device. + return $display->token; + } + } + + return $this->generateToken(); + } +} diff --git a/config/pos.php b/config/pos.php index 5fddda8..1a260db 100644 --- a/config/pos.php +++ b/config/pos.php @@ -49,4 +49,34 @@ return [ 'description' => 'Your own payment gateway, unlimited catalog, multi-branch & team — from GHS 790/mo.', 'route' => 'pos.pro.index', ], + + /* + |-------------------------------------------------------------------------- + | Hardware / screen devices (register, customer display, kitchen, …) + |-------------------------------------------------------------------------- + */ + 'device_types' => [ + 'register' => 'Register / till computer', + 'customer_display' => 'Customer display', + 'kitchen_display' => 'Kitchen display', + 'receipt_printer' => 'Receipt printer', + 'barcode_scanner' => 'Barcode scanner', + 'tablet' => 'Tablet', + ], + + /** Types that get a server-generated token for unattended open URLs / agents. */ + 'device_token_types' => [ + 'customer_display', + 'kitchen_display', + 'receipt_printer', + ], + + 'device_registration_hints' => [ + 'register' => 'Tracked for your team. Staff sign in on this machine — no device token is issued.', + 'customer_display' => 'Creates (or reuses) the branch customer-display token. Open the URL on a second screen facing the customer.', + 'kitchen_display' => 'Token for kitchen screen provisioning. Kitchen currently opens the signed-in Kitchen page for this account.', + 'receipt_printer' => 'Token for a future print agent. Paper size still comes from branch receipt settings.', + 'barcode_scanner' => 'Tracked for inventory. USB scanners work as keyboard wedges on the register today.', + 'tablet' => 'Tracked for inventory. Use Customer display or Kitchen display if the tablet should run unattended.', + ], ]; diff --git a/database/migrations/2026_07_15_220000_create_pos_devices_table.php b/database/migrations/2026_07_15_220000_create_pos_devices_table.php new file mode 100644 index 0000000..17949b4 --- /dev/null +++ b/database/migrations/2026_07_15_220000_create_pos_devices_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('owner_ref', 64)->index(); + $table->foreignId('location_id')->nullable()->constrained('pos_locations')->nullOnDelete(); + $table->string('name'); + $table->string('type', 40)->index(); + $table->string('status', 20)->default('offline')->index(); + $table->string('device_token', 64)->nullable()->unique(); + $table->json('config')->nullable(); + $table->timestamp('last_online_at')->nullable(); + $table->timestamps(); + + $table->index(['owner_ref', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('pos_devices'); + } +}; diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 2fb8b30..d77c61a 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -38,8 +38,15 @@
+ + + + + Devices + + class="group mt-0.5 flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ request()->routeIs('pos.settings*') ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}"> diff --git a/resources/views/pos/devices/create.blade.php b/resources/views/pos/devices/create.blade.php new file mode 100644 index 0000000..b5c766c --- /dev/null +++ b/resources/views/pos/devices/create.blade.php @@ -0,0 +1,51 @@ + +
+
+ ← Devices +

Register device

+

Name the hardware and link it to a branch when needed.

+
+ + @if (session('error')) +
{{ session('error') }}
+ @endif + +
+ @csrf +
+ + + @error('name')

{{ $message }}

@enderror +
+
+ + +

+ @error('type')

{{ $message }}

@enderror +
+
+ + +

Required for customer and kitchen displays.

+ @error('location_id')

{{ $message }}

@enderror +
+ +
+
+ diff --git a/resources/views/pos/devices/edit.blade.php b/resources/views/pos/devices/edit.blade.php new file mode 100644 index 0000000..a55eb8f --- /dev/null +++ b/resources/views/pos/devices/edit.blade.php @@ -0,0 +1,96 @@ + +
+
+ ← Devices +

Edit device

+

{{ $deviceTypes[$device->type] ?? $device->type }} · {{ $device->location?->name ?? 'No branch' }}

+
+ + @if (session('success')) +
{{ session('success') }}
+ @endif + +
+ @csrf + @method('PUT') +
+ + +
+
+ + +

+
+
+ + +
+
+ + +
+ + @if ($device->device_token) +
+

Device token

+ {{ $device->device_token }} + @if ($openUrl) + + Open device URL → + + @endif +

Last online: {{ $device->last_online_at?->diffForHumans() ?? 'Never' }}

+
+ @endif + +
+ Back + +
+
+ +
+

Device actions

+ @if ($device->device_token) +
+ @csrf + +
+ @endif +
+ @csrf + @method('DELETE') + +
+
+
+
diff --git a/resources/views/pos/devices/index.blade.php b/resources/views/pos/devices/index.blade.php new file mode 100644 index 0000000..92784a3 --- /dev/null +++ b/resources/views/pos/devices/index.blade.php @@ -0,0 +1,82 @@ + +
+
+
+

Hardware

+

Devices

+

Register tills, customer screens, kitchen displays, and printers for each branch.

+
+ Add device +
+ +
+
+

{{ number_format($heroStats['total']) }}

+

Devices

+
+
+

{{ number_format($heroStats['online']) }}

+

Online

+
+
+

{{ number_format($heroStats['displays']) }}

+

Displays

+
+
+ + @if (session('success')) +
{{ session('success') }}
+ @endif + @if (session('error')) +
{{ session('error') }}
+ @endif + +
+
+

All devices

+
+
+ + + + + + + + + + + + + @forelse ($devices as $device) + + + + + + + + + @empty + + + + @endforelse + +
NameTypeStatusBranchLast online
{{ $device->name }}{{ $deviceTypes[$device->type] ?? $device->type }} + + {{ $device->isOnline() ? 'Online' : ucfirst($device->status) }} + + {{ $device->location?->name ?? '—' }}{{ $device->last_online_at?->diffForHumans() ?? 'Never' }} + Manage +
+ No devices yet. + Add your first device +
+
+ @if ($devices->hasPages()) +
{{ $devices->links() }}
+ @endif +
+
+
diff --git a/routes/console.php b/routes/console.php index e0afadf..30976bd 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,3 +9,4 @@ Artisan::command('inspire', function () { })->purpose('Display an inspiring quote'); Schedule::command('pos:pro-renew')->dailyAt('02:50')->withoutOverlapping(); +Schedule::command('pos:devices-offline')->everyFiveMinutes()->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php index a4e7562..3bbf22c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Pos\MenuController; use App\Http\Controllers\Pos\ProductController; use App\Http\Controllers\Pos\ProController; use App\Http\Controllers\Pos\CustomerDisplayController; +use App\Http\Controllers\Pos\DeviceController; use App\Http\Controllers\Pos\RegisterController; use App\Http\Controllers\Pos\SaleController; use App\Http\Controllers\Pos\SettingsController; @@ -133,6 +134,15 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/settings', [SettingsController::class, 'index'])->name('pos.settings'); Route::put('/settings', [SettingsController::class, 'update'])->name('pos.settings.update'); + + Route::get('/devices', [DeviceController::class, 'index'])->name('pos.devices.index'); + Route::get('/devices/create', [DeviceController::class, 'create'])->name('pos.devices.create'); + Route::post('/devices', [DeviceController::class, 'store'])->name('pos.devices.store'); + Route::get('/devices/{device}/edit', [DeviceController::class, 'edit'])->name('pos.devices.edit'); + Route::put('/devices/{device}', [DeviceController::class, 'update'])->name('pos.devices.update'); + Route::delete('/devices/{device}', [DeviceController::class, 'destroy'])->name('pos.devices.destroy'); + Route::post('/devices/{device}/regenerate-token', [DeviceController::class, 'regenerateToken'])->name('pos.devices.regenerate-token'); + Route::get('/settings/branches', [LocationController::class, 'index'])->name('pos.branches.index'); Route::get('/settings/branches/create', [LocationController::class, 'create'])->name('pos.branches.create'); Route::post('/settings/branches', [LocationController::class, 'store'])->name('pos.branches.store'); diff --git a/tests/Feature/PosDeviceTest.php b/tests/Feature/PosDeviceTest.php new file mode 100644 index 0000000..c5aacf0 --- /dev/null +++ b/tests/Feature/PosDeviceTest.php @@ -0,0 +1,157 @@ +withoutMiddleware(EnsurePlatformSession::class); + $this->withoutVite(); + } + + private function user(): User + { + return User::create([ + 'public_id' => 'u-'.uniqid(), + 'name' => 'Owner', + 'email' => uniqid().'@example.com', + ]); + } + + private function location(User $user): PosLocation + { + return PosLocation::create([ + 'owner_ref' => $user->public_id, + 'name' => 'Main register', + 'currency' => 'GHS', + ]); + } + + public function test_can_register_register_device_without_token(): void + { + $user = $this->user(); + $location = $this->location($user); + + $this->actingAs($user) + ->post(route('pos.devices.store'), [ + 'name' => 'Front till', + 'type' => 'register', + 'location_id' => $location->id, + ]) + ->assertRedirect(route('pos.devices.index')); + + $device = PosDevice::query()->where('owner_ref', $user->public_id)->first(); + $this->assertNotNull($device); + $this->assertSame('register', $device->type); + $this->assertNull($device->device_token); + } + + public function test_customer_display_device_reuses_branch_display_token(): void + { + $user = $this->user(); + $location = $this->location($user); + + $this->actingAs($user) + ->post(route('pos.devices.store'), [ + 'name' => 'Counter screen', + 'type' => 'customer_display', + 'location_id' => $location->id, + ]) + ->assertRedirect(route('pos.devices.index')); + + $device = PosDevice::query()->where('owner_ref', $user->public_id)->firstOrFail(); + $display = PosCustomerDisplay::query()->where('location_id', $location->id)->firstOrFail(); + + $this->assertNotEmpty($device->device_token); + $this->assertSame($display->token, $device->device_token); + } + + public function test_customer_display_device_requires_branch(): void + { + $user = $this->user(); + $this->location($user); + + $this->actingAs($user) + ->from(route('pos.devices.create')) + ->post(route('pos.devices.store'), [ + 'name' => 'Orphan screen', + 'type' => 'customer_display', + 'location_id' => '', + ]) + ->assertRedirect(route('pos.devices.create')) + ->assertSessionHas('error'); + } + + public function test_devices_index_lists_owned_devices(): void + { + $user = $this->user(); + $location = $this->location($user); + PosDevice::create([ + 'owner_ref' => $user->public_id, + 'location_id' => $location->id, + 'name' => 'Kitchen pad', + 'type' => 'tablet', + 'status' => 'offline', + ]); + + $this->actingAs($user) + ->get(route('pos.devices.index')) + ->assertOk() + ->assertSee('Kitchen pad') + ->assertSee('Devices'); + } + + public function test_regenerate_token_updates_customer_display(): void + { + $user = $this->user(); + $location = $this->location($user); + $device = app(DeviceService::class)->register([ + 'owner_ref' => $user->public_id, + 'name' => 'Display', + 'type' => 'customer_display', + 'location_id' => $location->id, + ]); + $old = $device->device_token; + + $this->actingAs($user) + ->post(route('pos.devices.regenerate-token', $device)) + ->assertRedirect(); + + $device->refresh(); + $display = PosCustomerDisplay::query()->where('location_id', $location->id)->firstOrFail(); + $this->assertNotSame($old, $device->device_token); + $this->assertSame($device->device_token, $display->token); + } + + public function test_customer_display_poll_marks_device_online(): void + { + $user = $this->user(); + $location = $this->location($user); + $device = app(DeviceService::class)->register([ + 'owner_ref' => $user->public_id, + 'name' => 'Display', + 'type' => 'customer_display', + 'location_id' => $location->id, + ]); + + $this->getJson(route('pos.customer-display.state', ['token' => $device->device_token])) + ->assertOk(); + + $device->refresh(); + $this->assertSame('online', $device->status); + $this->assertNotNull($device->last_online_at); + } +}