From c01518b0ee273b0f354ef0ea62368134e9094950 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 15 Jul 2026 21:49:21 +0000 Subject: [PATCH] Add Frontdesk-style Devices registry for POS hardware. Register tills, customer displays, kitchen screens, printers, scanners, and tablets per branch. Customer displays share the branch display token and mark online when the public screen polls; stale devices go offline on a schedule. --- README.md | 5 +- .../Commands/MarkDevicesOfflineCommand.php | 22 +++ app/Http/Controllers/Pos/DeviceController.php | 172 ++++++++++++++++++ .../Public/CustomerDisplayController.php | 4 + app/Models/PosDevice.php | 62 +++++++ app/Services/Pos/DeviceService.php | 148 +++++++++++++++ config/pos.php | 30 +++ ..._07_15_220000_create_pos_devices_table.php | 31 ++++ resources/views/partials/sidebar.blade.php | 9 +- resources/views/pos/devices/create.blade.php | 51 ++++++ resources/views/pos/devices/edit.blade.php | 96 ++++++++++ resources/views/pos/devices/index.blade.php | 82 +++++++++ routes/console.php | 1 + routes/web.php | 10 + tests/Feature/PosDeviceTest.php | 157 ++++++++++++++++ 15 files changed, 877 insertions(+), 3 deletions(-) create mode 100644 app/Console/Commands/MarkDevicesOfflineCommand.php create mode 100644 app/Http/Controllers/Pos/DeviceController.php create mode 100644 app/Models/PosDevice.php create mode 100644 app/Services/Pos/DeviceService.php create mode 100644 database/migrations/2026_07_15_220000_create_pos_devices_table.php create mode 100644 resources/views/pos/devices/create.blade.php create mode 100644 resources/views/pos/devices/edit.blade.php create mode 100644 resources/views/pos/devices/index.blade.php create mode 100644 tests/Feature/PosDeviceTest.php 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); + } +}