Work

Touch-kiosk point of sale

A self-hosted touch-kiosk point of sale for a members' club canteen, taking card and cash, applying a member discount automatically, and keeping stock and takings honest.

Touch kiosk Native iPad app Web admin Card payments Self-hosted

The problem

The canteen counter ran without a till. Prices lived wherever they had last been written down, the discount members were entitled to had to be worked out by hand at the point of sale, and the day's takings were whatever ended up in the tin.

Nothing recorded what had actually sold, so restocking was guesswork and stock loss was invisible by construction: there was no expected figure for a shelf count to be measured against. Card payment was not an option at all, which left every sale dependent on whoever happened to have coins on them.

What was built

The kiosk is a single unattended screen. The buyer identifies themselves with a member number, the menu appears grouped by category, items go into a cart with quantity and variation pickers, and checkout offers card or cash. Cash is settled on an on-screen change calculator with quick-tender buttons and Australian five-cent rounding. Card is handed to Square: originally by launching Square's own point-of-sale app and catching the callback, and now by a native iPad app that takes the payment on-device through Square's Mobile Payments SDK with a Bluetooth reader.

Either route, the server never takes the client's word for it. It recomputes the order total from the catalog and rejects any line whose submitted unit price does not match a real item or variation price. For a card sale it looks the payment up in Square by id and refuses to record the sale unless it comes back completed for exactly the expected amount; a failed verification returns a payment-required error rather than storing anything.

The member discount is a mechanism rather than a login. A member number entered at the start is checked against a register, a verified number gets a configurable percentage off every line, fifteen per cent by default, with the full price struck through beside it, and the same check is re-run server-side at the moment the sale is recorded, so a discount cannot be forged from the client. Banked member credit is then drawn down against the order total on top of that percentage.

Behind a PIN-gated admin console sit items and categories, the member register, transactions and refunds, stocktakes, receipt-driven restocking and a set of reports. The checkout endpoints are reachable from the public internet for Square's callback, so sale recording is additionally restricted to paired devices. A member number alone can never record a sale from outside the kiosk.

Platform

The server is deliberately small: a handful of production dependencies, one database file, no bundler and no frontend framework anywhere in the stack.

Server
Node.js 18 or newer with Express. Production dependencies are just better-sqlite3, dotenv, express and multer.
Database
SQLite through better-sqlite3 as a single file, with the schema and every migration applied in code at startup.
Admin console
Vanilla ES modules in the browser. No bundler, no framework, no build step.
Kiosk
A native iPadOS app in Swift and SwiftUI, backed by a local Swift package. On-device card payments are configured online-only rather than Square's offline or auto-detect mode, because an offline-processed payment's real id is not available for the server's synchronous verification step.
Payments
Square end to end: the Catalog API for items, prices and images; Payments and Refunds for verification and money back; hosted quick-pay checkout links for remote requests; the Mobile Payments SDK on the iPad for card-present sales; and signed webhooks for changes made in Square's own dashboard, verified before anything is acted on.
Catalog sync
Items pull from Square on startup and then every 60 minutes. Four background schedulers run in total: catalog sync, payment-token refresh, a daily reorder check, and a five-minutely kiosk-liveness check.
Live updates
Server-Sent Events carrying only a topic name. Clients keep their periodic poll as a safety net, so a screen that missed an event still converges.
Receipts
Client-side OCR with Tesseract.js and pdf.js reads supplier receipts in the browser, without the image ever leaving the device.
Email
Brevo for transactional mail: alerts and admin notifications.
Hosting
PM2 with a capped restart policy on a small self-hosted Linux box, reached through a Cloudflare Tunnel rather than a port-forward or static IP, so the host can move without any client rebuild. A nightly cron job backs the database up with 14-day retention.

Two small serving decisions do real work. Item photos go out with a one-year immutable cache header while every other static asset is served no-store, so a deploy is never served stale but photos are never refetched. And the process handles SIGINT and SIGTERM to close every open event-stream connection cleanly before exiting, with a five-second fallback so shutdown cannot hang.

What it does now

It runs the counter day to day. Sales go through it in card and cash, landing in a local database that reports takings, per-item breakdowns, popular and slow-moving lines, item profitability, money spent restocking, sales per member, and a card-versus-cash split over any date range. That is six reporting endpoints plus a dashboard summary.

Stock is live. A sale decrements it, a confirmed supplier receipt adds to it, a stocktake rebaselines it, and a write-off with a reason records a known loss so the shrinkage report stops counting it as a mystery. Write-offs are kept in a separate table from stocktake variance, with a snapshot of unit cost at the moment of write-off. Receipt-to-item matching learns as it goes: every admin-confirmed mapping is stored against the normalised OCR text, so the same product matches instantly next time.

Refunds can be scoped to individual item lines rather than the whole order, calling Square for exactly the real money owed, in AUD and with an idempotency key, and restoring any banked member credit in proportion. Each refunded line is tracked with a uniqueness constraint on the sale and line index, so the same line can never be refunded twice.

Alerts email the admins who opt into them: low stock, a card reader that has dropped out, a kiosk that has stopped checking in, a disputed payment, a revoked payment authorisation, a payment link that cannot safely be turned into a sale. The kiosk sends a liveness heartbeat every five minutes; after 20 minutes of silence the server alerts once for that outage and clears the flag on the first heartbeat back. Every request to the admin API, failed logins included, is written to an audit log with the actor label, IP address, method, path, status and a readable description of anything sensitive.

What it does not do is trade offline. Card processing is online-only on purpose and the server is authoritative on every total, so a kiosk that has lost the network cannot record a sale. The offline story here is detection and alerting, not degraded trading.

Continued development

The project is still in active development. The hand-written changelog holds dozens of released entries since the first version, and through the build-out releases often landed a day or two apart, across well over a hundred commits.

Releases follow a two-branch model: work accumulates on a staging branch and the master branch only ever holds deployed versions. A validation script syntax-checks every JavaScript file and fails the build if the newest changelog entry or the lockfile drifts from the version in the manifest. The deploy script refuses to run from any branch but master, refuses any version that is not strictly greater than the one already live, restarts the process manager, then polls the running server's version endpoint to confirm the deploy actually took.

More than a hundred test cases run under Node's built-in test runner, concentrated on the places where being wrong costs money: pricing, refunds and partial refunds, payment verification, payment links, webhook handling and stock movement.

Not every feature in the older entries survives. A browser-based checkout and a parallel second card processor were both built and then explicitly retired, along with a standalone second admin app that never got past a placeholder before being folded back into the app the kiosk already runs; the root URL now redirects to the admin console and the web surface is admin-only. Removing things that stopped earning their place is part of the same job as adding them.

This project is not linked from this page. It runs on-site as a kiosk for a private members' club, so there is no public instance to link to.

This is an independent product built and owned by PiStack. It is not affiliated with, endorsed by, sponsored by, or contracted to any government organisation or agency.

Get in touch