From 9a2d196d846400f1646fcf1a1225a5f87213200f Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 8 Jul 2026 05:40:14 +0000 Subject: [PATCH] Add barcode scanner, thermal receipt printing, and printer settings. Register supports USB keyboard-wedge scanners with SKU lookup (local catalog and CRM in retail mode). Sales get a thermal receipt print template (58/80mm) with configurable header/footer, plus optional auto-print after cash sales. Co-authored-by: Cursor --- .../Controllers/Pos/RegisterController.php | 42 +++++- app/Http/Controllers/Pos/SaleController.php | 12 ++ .../Controllers/Pos/SettingsController.php | 6 + app/Models/PosLocation.php | 11 ++ app/Services/Pos/PosBarcodeLookupService.php | 78 ++++++++++ ...eipt_printer_settings_to_pos_locations.php | 24 +++ resources/views/pos/receipts/print.blade.php | 126 ++++++++++++++++ resources/views/pos/register.blade.php | 93 ++++++++++++ resources/views/pos/sales/show.blade.php | 7 + resources/views/pos/settings.blade.php | 28 ++++ routes/web.php | 2 + tests/Feature/PosHardwareTest.php | 138 ++++++++++++++++++ tests/Feature/PosRegisterTest.php | 2 +- 13 files changed, 565 insertions(+), 4 deletions(-) create mode 100644 app/Services/Pos/PosBarcodeLookupService.php create mode 100644 database/migrations/2026_07_08_120000_add_receipt_printer_settings_to_pos_locations.php create mode 100644 resources/views/pos/receipts/print.blade.php create mode 100644 tests/Feature/PosHardwareTest.php diff --git a/app/Http/Controllers/Pos/RegisterController.php b/app/Http/Controllers/Pos/RegisterController.php index cf43d91..1d5843f 100644 --- a/app/Http/Controllers/Pos/RegisterController.php +++ b/app/Http/Controllers/Pos/RegisterController.php @@ -7,9 +7,11 @@ use App\Http\Controllers\Pos\Concerns\ScopesToAccount; use App\Models\PosLocation; use App\Models\PosProduct; use App\Models\PosSale; +use App\Services\Pos\PosBarcodeLookupService; use App\Services\Pos\PosLocationService; use App\Services\Pos\PosSaleService; use App\Services\Crm\CrmClient; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -19,7 +21,11 @@ class RegisterController extends Controller { use ScopesToAccount; - public function __construct(private PosSaleService $sales, private PosLocationService $locations) {} + public function __construct( + private PosSaleService $sales, + private PosLocationService $locations, + private PosBarcodeLookupService $barcodes, + ) {} public function index(Request $request): View { @@ -30,20 +36,43 @@ class RegisterController extends Controller 'products' => $this->registerProducts($owner, $location), 'location' => $location, 'crmCustomers' => $this->crmCustomers($owner), + 'barcodeLookupUrl' => route('pos.register.lookup'), ]); } + public function lookup(Request $request): JsonResponse + { + $data = $request->validate([ + 'code' => ['required', 'string', 'max:80'], + ]); + + $owner = $this->ownerRef($request); + $location = $this->locations->ensureDefault($owner); + $product = $this->barcodes->lookup($owner, $location, $data['code']); + + if (! $product || $product['price_minor'] <= 0 || $product['name'] === '') { + return response()->json(['message' => 'No product found for that barcode.'], 404); + } + + return response()->json(['product' => $product]); + } + /** * Register catalog: local pos_products in restaurant mode, live CRM products * in retail mode (resilient — empty if CRM is unreachable). * - * @return list + * @return list */ private function registerProducts(string $owner, PosLocation $location): array { if ($location->isRestaurant()) { return PosProduct::owned($owner)->active()->orderBy('name')->get() - ->map(fn (PosProduct $p) => ['id' => $p->id, 'name' => $p->name, 'price_minor' => $p->price_minor]) + ->map(fn (PosProduct $p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'price_minor' => $p->price_minor, + 'sku' => $p->sku, + ]) ->all(); } @@ -58,6 +87,7 @@ class RegisterController extends Controller 'id' => $r['id'] ?? null, 'name' => (string) ($r['name'] ?? ''), 'price_minor' => (int) ($r['unit_price_minor'] ?? 0), + 'sku' => isset($r['sku']) && $r['sku'] !== '' ? (string) $r['sku'] : null, ]) ->filter(fn ($r) => $r['name'] !== '' && $r['price_minor'] > 0) ->values() @@ -126,6 +156,12 @@ class RegisterController extends Controller if ($data['payment_method'] === 'cash') { $this->sales->recordCashPayment($sale); + if ($location->printer_auto_print) { + return redirect() + ->route('pos.sales.receipt', ['sale' => $sale, 'autoprint' => 1]) + ->with('success', 'Cash sale recorded.'); + } + return redirect() ->route('pos.sales.show', $sale) ->with('success', 'Cash sale recorded.'); diff --git a/app/Http/Controllers/Pos/SaleController.php b/app/Http/Controllers/Pos/SaleController.php index e8462b5..2aeef10 100644 --- a/app/Http/Controllers/Pos/SaleController.php +++ b/app/Http/Controllers/Pos/SaleController.php @@ -44,6 +44,18 @@ class SaleController extends Controller return view('pos.sales.show', compact('sale', 'invoiceUrl')); } + public function receipt(Request $request, PosSale $sale): View + { + $this->authorizeOwner($request, $sale); + $sale->load('lines', 'location'); + + return view('pos.receipts.print', [ + 'sale' => $sale, + 'location' => $sale->location, + 'autoprint' => $request->boolean('autoprint'), + ]); + } + public function cancel(Request $request, PosSale $sale): RedirectResponse { $this->authorizeOwner($request, $sale); diff --git a/app/Http/Controllers/Pos/SettingsController.php b/app/Http/Controllers/Pos/SettingsController.php index d552193..1ce2b1b 100644 --- a/app/Http/Controllers/Pos/SettingsController.php +++ b/app/Http/Controllers/Pos/SettingsController.php @@ -42,6 +42,9 @@ class SettingsController extends Controller 'currency' => ['required', 'string', 'size:3'], 'service_style' => ['required', 'in:retail,restaurant'], 'receipt_footer' => ['nullable', 'string', 'max:1000'], + 'receipt_header' => ['nullable', 'string', 'max:500'], + 'printer_paper_mm' => ['required', 'in:58,80'], + 'printer_auto_print' => ['sometimes', 'boolean'], ]); $user = ladill_account() ?? $request->user(); @@ -56,6 +59,9 @@ class SettingsController extends Controller 'currency' => strtoupper($data['currency']), 'service_style' => $data['service_style'], 'receipt_footer' => $data['receipt_footer'] ?? null, + 'receipt_header' => $data['receipt_header'] ?? null, + 'printer_paper_mm' => (int) $data['printer_paper_mm'], + 'printer_auto_print' => $request->boolean('printer_auto_print'), ]); return back()->with('success', 'Settings saved.'); diff --git a/app/Models/PosLocation.php b/app/Models/PosLocation.php index 833d5ad..8ecc0f1 100644 --- a/app/Models/PosLocation.php +++ b/app/Models/PosLocation.php @@ -18,8 +18,19 @@ class PosLocation extends Model 'currency', 'service_style', 'receipt_footer', + 'receipt_header', + 'printer_paper_mm', + 'printer_auto_print', ]; + protected function casts(): array + { + return [ + 'printer_paper_mm' => 'integer', + 'printer_auto_print' => 'boolean', + ]; + } + public function isRestaurant(): bool { return $this->service_style === self::STYLE_RESTAURANT; diff --git a/app/Services/Pos/PosBarcodeLookupService.php b/app/Services/Pos/PosBarcodeLookupService.php new file mode 100644 index 0000000..a9a44a2 --- /dev/null +++ b/app/Services/Pos/PosBarcodeLookupService.php @@ -0,0 +1,78 @@ +isRestaurant()) { + return $this->lookupLocal($ownerRef, $code); + } + + return $this->lookupRetail($ownerRef, $code) ?? $this->lookupLocal($ownerRef, $code); + } + + /** @return array{id: int|string|null, name: string, price_minor: int, sku: string|null}|null */ + private function lookupLocal(string $ownerRef, string $code): ?array + { + $product = PosProduct::owned($ownerRef) + ->active() + ->where('sku', $code) + ->first(); + + if (! $product) { + return null; + } + + return [ + 'id' => $product->id, + 'name' => $product->name, + 'price_minor' => $product->price_minor, + 'sku' => $product->sku, + ]; + } + + /** @return array{id: int|string|null, name: string, price_minor: int, sku: string|null}|null */ + private function lookupRetail(string $ownerRef, string $code): ?array + { + try { + $rows = (array) (CrmClient::for($ownerRef)->products([ + 'search' => $code, + 'type' => 'product', + 'active' => 1, + 'per_page' => 25, + ])['data'] ?? []); + } catch (\Throwable) { + return null; + } + + foreach ($rows as $row) { + $sku = isset($row['sku']) ? (string) $row['sku'] : ''; + if ($sku !== '' && strcasecmp($sku, $code) === 0) { + return [ + 'id' => $row['id'] ?? null, + 'name' => (string) ($row['name'] ?? ''), + 'price_minor' => (int) ($row['unit_price_minor'] ?? 0), + 'sku' => $sku, + ]; + } + } + + return null; + } +} diff --git a/database/migrations/2026_07_08_120000_add_receipt_printer_settings_to_pos_locations.php b/database/migrations/2026_07_08_120000_add_receipt_printer_settings_to_pos_locations.php new file mode 100644 index 0000000..b7e4f52 --- /dev/null +++ b/database/migrations/2026_07_08_120000_add_receipt_printer_settings_to_pos_locations.php @@ -0,0 +1,24 @@ +string('receipt_header', 500)->nullable()->after('receipt_footer'); + $table->unsignedTinyInteger('printer_paper_mm')->default(80)->after('receipt_header'); + $table->boolean('printer_auto_print')->default(false)->after('printer_paper_mm'); + }); + } + + public function down(): void + { + Schema::table('pos_locations', function (Blueprint $table) { + $table->dropColumn(['receipt_header', 'printer_paper_mm', 'printer_auto_print']); + }); + } +}; diff --git a/resources/views/pos/receipts/print.blade.php b/resources/views/pos/receipts/print.blade.php new file mode 100644 index 0000000..596ba24 --- /dev/null +++ b/resources/views/pos/receipts/print.blade.php @@ -0,0 +1,126 @@ + + + + + + Receipt {{ $sale->reference }} + @include('partials.favicon') + @php + $paper = in_array((int) ($location?->printer_paper_mm ?? 80), [58, 80], true) + ? (int) $location->printer_paper_mm + : 80; + @endphp + + + +
+ @if ($location?->receipt_header) +

{{ $location->receipt_header }}

+ @endif +

{{ $location?->name ?? 'Ladill POS' }}

+

{{ $sale->reference }}

+

{{ $sale->created_at->format('M j, Y g:i A') }}

+ +
+ + + @foreach ($sale->lines as $line) + + + + + + @endforeach +
{{ $line->quantity }}×{{ $line->name }}{{ pos_money($line->line_total_minor, $sale->currency) }}
+ +
+ + + + + + + + + + +
Total{{ pos_money($sale->total_minor, $sale->currency) }}
Payment{{ ucfirst($sale->payment_method) }}
+ + @if ($sale->customer_name) +

Customer: {{ $sale->customer_name }}

+ @endif + + @if ($location?->receipt_footer) +
+

{{ $location->receipt_footer }}

+ @endif + +

Thank you

+
+ +
+ + Back to sale + Register +
+ + @if ($autoprint) + + @endif + + diff --git a/resources/views/pos/register.blade.php b/resources/views/pos/register.blade.php index cb4630d..e10934a 100644 --- a/resources/views/pos/register.blade.php +++ b/resources/views/pos/register.blade.php @@ -1,6 +1,8 @@
@@ -32,11 +34,27 @@
+
+ +

Scan with a USB scanner or type a SKU/barcode and press Enter.

+
+ + +
+

+

+
+
@@ -108,9 +126,44 @@ return { tab: 'catalog', products: config.products || [], + lookupUrl: config.lookupUrl || '', + csrf: config.csrf || '', cart: [], quickAmount: '', quickLabel: 'Sale', + scanCode: '', + scanError: '', + scanOk: '', + scanBuffer: '', + scanTimer: null, + init() { + document.addEventListener('keydown', (e) => this.onGlobalKeydown(e)); + }, + onGlobalKeydown(e) { + if (this.shouldIgnoreScan(e)) return; + if (e.key === 'Enter') { + const code = (this.scanBuffer || '').trim(); + this.scanBuffer = ''; + if (code.length >= 3) { + e.preventDefault(); + this.scanCode = code; + this.submitScan(); + } + return; + } + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + this.scanBuffer += e.key; + clearTimeout(this.scanTimer); + this.scanTimer = setTimeout(() => { this.scanBuffer = ''; }, 120); + } + }, + shouldIgnoreScan(e) { + const tag = (e.target?.tagName || '').toLowerCase(); + if (tag === 'textarea') return true; + if (tag === 'input' && e.target?.id !== 'barcode-scan') return true; + if (e.target?.isContentEditable) return true; + return false; + }, addProduct(product) { const existing = this.cart.find(l => l.product_id === product.id); if (existing) { @@ -123,6 +176,46 @@ quantity: 1, }); } + this.scanError = ''; + this.scanOk = product.name + ' added'; + setTimeout(() => { this.scanOk = ''; }, 1500); + }, + async submitScan() { + const code = (this.scanCode || '').trim(); + this.scanError = ''; + this.scanOk = ''; + if (!code) return; + + const local = this.products.find(p => p.sku && String(p.sku).toLowerCase() === code.toLowerCase()); + if (local) { + this.addProduct(local); + this.scanCode = ''; + return; + } + + if (!this.lookupUrl) { + this.scanError = 'No product found for that barcode.'; + return; + } + + try { + const res = await fetch(this.lookupUrl + '?' + new URLSearchParams({ code }), { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': this.csrf, + }, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + this.scanError = data.message || 'No product found for that barcode.'; + return; + } + this.addProduct(data.product); + this.scanCode = ''; + } catch (err) { + this.scanError = 'Barcode lookup failed. Try again.'; + } }, addQuick() { const amount = Math.round(parseFloat(this.quickAmount || 0) * 100); diff --git a/resources/views/pos/sales/show.blade.php b/resources/views/pos/sales/show.blade.php index 53dac61..7dfb55a 100644 --- a/resources/views/pos/sales/show.blade.php +++ b/resources/views/pos/sales/show.blade.php @@ -48,6 +48,13 @@

{{ $sale->location->receipt_footer }}

@endif + + @if ($sale->isPaid() && !empty($invoiceUrl))
Create invoice diff --git a/resources/views/pos/settings.blade.php b/resources/views/pos/settings.blade.php index 1068800..7c99d29 100644 --- a/resources/views/pos/settings.blade.php +++ b/resources/views/pos/settings.blade.php @@ -28,6 +28,34 @@
+
+

Receipt & printer

+

Thermal receipt layout for browser print (58mm or 80mm paper). USB barcode scanners work as keyboard wedges on the register.

+
+
+ + +
+
+ + +
+
diff --git a/routes/web.php b/routes/web.php index 88c715f..dbc8012 100644 --- a/routes/web.php +++ b/routes/web.php @@ -51,10 +51,12 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/dashboard', [DashboardController::class, 'index'])->name('pos.dashboard'); Route::get('/register', [RegisterController::class, 'index'])->name('pos.register'); + Route::get('/register/lookup', [RegisterController::class, 'lookup'])->name('pos.register.lookup'); Route::post('/register/charge', [RegisterController::class, 'charge'])->name('pos.register.charge'); Route::post('/register/mode', [RegisterController::class, 'setMode'])->name('pos.mode.set'); Route::get('/sales', [SaleController::class, 'index'])->name('pos.sales.index'); + Route::get('/sales/{sale}/receipt', [SaleController::class, 'receipt'])->name('pos.sales.receipt'); Route::get('/sales/{sale}', [SaleController::class, 'show'])->name('pos.sales.show'); Route::post('/sales/{sale}/cancel', [SaleController::class, 'cancel'])->name('pos.sales.cancel'); Route::delete('/sales/{sale}', [SaleController::class, 'destroy'])->name('pos.sales.destroy'); diff --git a/tests/Feature/PosHardwareTest.php b/tests/Feature/PosHardwareTest.php new file mode 100644 index 0000000..dfefcd4 --- /dev/null +++ b/tests/Feature/PosHardwareTest.php @@ -0,0 +1,138 @@ + 'u-'.uniqid(), + 'name' => 'Cashier', + 'email' => uniqid().'@example.com', + ]); + } + + protected function setUp(): void + { + parent::setUp(); + $this->withoutMiddleware(EnsurePlatformSession::class); + $this->withoutVite(); + + config([ + 'crm.url' => 'https://crm.test/api', + 'crm.key' => 'test-crm-key', + ]); + } + + public function test_barcode_lookup_finds_local_product_in_restaurant_mode(): void + { + $user = $this->user(); + PosLocation::create([ + 'owner_ref' => $user->public_id, + 'name' => 'Main register', + 'currency' => 'GHS', + 'service_style' => 'restaurant', + ]); + PosProduct::create([ + 'owner_ref' => $user->public_id, + 'name' => 'Water', + 'sku' => 'H2O-001', + 'price_minor' => 500, + 'currency' => 'GHS', + 'is_active' => true, + ]); + + $this->actingAs($user) + ->getJson(route('pos.register.lookup', ['code' => 'H2O-001'])) + ->assertOk() + ->assertJsonPath('product.name', 'Water') + ->assertJsonPath('product.price_minor', 500); + } + + public function test_barcode_lookup_returns_404_for_unknown_code(): void + { + $user = $this->user(); + PosLocation::create([ + 'owner_ref' => $user->public_id, + 'name' => 'Main register', + 'currency' => 'GHS', + 'service_style' => 'restaurant', + ]); + + $this->actingAs($user) + ->getJson(route('pos.register.lookup', ['code' => 'MISSING'])) + ->assertNotFound(); + } + + public function test_receipt_page_renders_thermal_layout(): void + { + $user = $this->user(); + $location = PosLocation::create([ + 'owner_ref' => $user->public_id, + 'name' => 'Main register', + 'currency' => 'GHS', + 'receipt_header' => 'Acme Shop', + 'printer_paper_mm' => 58, + ]); + $sale = PosSale::create([ + 'owner_ref' => $user->public_id, + 'location_id' => $location->id, + 'reference' => 'POS-TEST', + 'currency' => 'GHS', + 'status' => 'paid', + 'payment_method' => 'cash', + 'total_minor' => 1000, + ]); + $sale->lines()->create([ + 'name' => 'Tea', + 'unit_price_minor' => 1000, + 'quantity' => 1, + 'line_total_minor' => 1000, + ]); + + $this->actingAs($user) + ->get(route('pos.sales.receipt', $sale)) + ->assertOk() + ->assertSee('Acme Shop') + ->assertSee('POS-TEST') + ->assertSee('Tea') + ->assertSee('Print receipt'); + } + + public function test_cash_sale_redirects_to_receipt_when_auto_print_enabled(): void + { + Http::fake([ + 'crm.test/api/customers*' => Http::response(['data' => []], 200), + ]); + + $user = $this->user(); + PosLocation::create([ + 'owner_ref' => $user->public_id, + 'name' => 'Main register', + 'currency' => 'GHS', + 'printer_auto_print' => true, + ]); + + $response = $this->actingAs($user)->post(route('pos.register.charge'), [ + 'payment_method' => 'cash', + 'lines' => [ + ['name' => 'Snack', 'unit_price_minor' => 500, 'quantity' => 1], + ], + ]); + + $sale = PosSale::where('owner_ref', $user->public_id)->firstOrFail(); + $response->assertRedirect(route('pos.sales.receipt', ['sale' => $sale, 'autoprint' => 1])); + } +} diff --git a/tests/Feature/PosRegisterTest.php b/tests/Feature/PosRegisterTest.php index f89f1a8..491229e 100644 --- a/tests/Feature/PosRegisterTest.php +++ b/tests/Feature/PosRegisterTest.php @@ -65,7 +65,7 @@ class PosRegisterTest extends TestCase PosLocation::create(['owner_ref' => $user->public_id, 'name' => 'Main register', 'currency' => 'GHS', 'service_style' => 'restaurant']); PosProduct::create(['owner_ref' => $user->public_id, 'name' => 'Coffee', 'price_minor' => 1500, 'currency' => 'GHS', 'is_active' => true]); - $this->actingAs($user)->get(route('pos.register'))->assertOk()->assertSee('Coffee'); + $this->actingAs($user)->get(route('pos.register'))->assertOk()->assertSee('Coffee')->assertSee('Barcode scanner'); } public function test_retail_register_reads_crm_products_and_snapshots_lines(): void