Building Kasse - A Point of Sale for a CrĂŞpe Stand
Published on Oct 26, 2026
Kasse is the till for de Luc’s Büx, the crêpe wagon I run together with Bernard Gerber. It runs on the tablets at the stand, on the kitchen’s tablet behind it and on a pickup display for the guests. It books receipts, takes cash, TWINT and card, feeds the kitchen, counts the cash drawer per stand at the end of the evening and tells us afterwards what sold when.
This post is about how it is built and why it is built that way. The short version: one SvelteKit single-page app, one PocketBase binary with Go hooks, one container, and a stubborn rule that nothing about money is decided in the browser.
Table of Contents
- Why Build a Till
- The Stack
- System Architecture
- Money Is an Integer
- Rules Live on the Server
- Checkout Is One Transaction
- The Kitchen Cannot Double-Take
- Stands, Shifts and the Cash Count
- Card Payments Without a Cloud Reader
- Offline: the Device Knows, the Socket Does Not
- Trash, Not Delete
- Data Model
- Deployment
- What Went Wrong
- Summary
Why Build a Till
At 200 crĂŞpes on a market Saturday, paper and mental arithmetic stop working. The cloud POS products either wanted a monthly fee per device, a login per employee, or treated a sold-out topping as a stock-management problem. What we needed was narrower and odder: a product grid tuned for thumbs on a sunny day, extras on a crĂŞpe, a kitchen that sees paid orders and nothing else, receipt numbers short enough to shout across a market square, and a cash count per stand because we sometimes run two.
Kasse was commissioned, in my own notes, “following the architecture of KnowMore”. It is the third app in the family after KnowMore and PlayMore, and it borrows their stack and most of their decisions.
The Stack
| Layer | Technology | Why |
|---|---|---|
| Frontend | SvelteKit 3 + Svelte 5, adapter-static | Runes, small bundle, pure SPA — no server rendering needed |
| UI | Tailwind CSS 4, shadcn-svelte wrappers on bits-ui | Tokens from the printed menu card; accessible primitives |
| Backend | PocketBase 0.40, custom Go binary | Auth, SQLite, real-time, file storage in one process |
| Go packages | rules, push, branding | Every invariant, Web Push, manifest and icons from the brand |
| Payments | SumUp Payment Switch (app hand-off) | The only route from a PWA to the Bluetooth reader we own |
| PWA | Own service worker (workbox-precaching) | App shell offline, API never from cache |
| Deployment | One Docker image on Unraid behind SWAG | PocketBase serves the SPA from pb_public |
The guiding principle is the same as in the sibling apps: fewer moving parts. One process, one database file, one container, same origin for everything.
System Architecture
PocketBase serves the built SPA and is the API at the same time. There is no CORS, no second deployment and no environment variable for the API base: the client only ever talks to / (pb/main.go:57). In development the Vite proxy recreates the same situation for /api and /_.
The devices do not talk to each other. A till books a receipt; the server validates it and pushes a real-time event; the kitchen tablet and the pickup display subscribe to that event with a filter for their own event, so a tablet only receives what concerns it.
Money Is an Integer
0.1 + 0.2 is not a curiosity in a till, it is a bug: a float sum drifts, and at the end of the evening the drawer does not match the report. Every amount in Kasse is an integer number of Rappen. Formatting happens in the display layer and nowhere else (src/lib/money.ts):
export function formatRappen(rappen: number): string {
const sign = rappen < 0 ? '-' : '';
const abs = Math.abs(Math.round(rappen));
return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;
}
export function parseRappen(input: string): number {
const match = /^\s*(\d+)(?:[.,](\d{1,2}))?\s*$/.exec(input);
if (!match) throw new Error(`Kein Betrag: "${input}"`);
return Number(match[1]) * 100 + Number((match[2] ?? '0').padEnd(2, '0'));
}The second rule is the price snapshot. When a line is sold, the product’s name and price are copied into the sales row. Every report sums sales.unit_price_rappen, never the relation to products.price_rappen. Otherwise a price increase in spring rewrites last year’s revenue.
That rule is not a convention, it is a hook. The client may send whatever it likes in unit_price_rappen; the server overwrites it (pb/rules/rules.go:167):
There is exactly one case where the price comes from the client: a product flagged open_price, the “free amount” button for odd requests. Even then the hook refuses anything below one Rappen, because zero is a typo, not a gift.
Rules Live on the Server
PocketBase gives you collection rules, small filter expressions evaluated before a write. Kasse uses them for visibility, but everything that has to be atomic or derived lives in Go hooks (pb/rules/). The notes keep a table of invariants with the hook that enforces each and the error a violation produces. A sample:
| Invariant | Enforced in | On violation |
|---|---|---|
| Name and price are copied into the line | onSaleCreate | client values silently replaced |
| A new receipt is empty: only event comes from the client | onOrderCreate | number, paid flag, payment, kitchen fields dropped |
| ”Paid” is set by the server, at checkout | orderUpdateInTx | client paid_at dropped |
| Receipt total is the sum of its lines | recalcOrderTotal | client value ignored |
| A receipt needs an active till shift of this account at this event | requireKasseShift | 400 “Zuerst am Event teilnehmen” |
| A closed event takes no receipts | requireOpenEvent | 400 |
| A sold product cannot be deleted | onProductDelete | 400 “deactivate instead” |
| Nobody deletes an evening through the API | events.deleteRule = nil | 403 |
The receipt total is recomputed after every change to its lines with OnRecordAfterCreate/Update/DeleteSuccess on sales (pb/rules/rules.go:24), written with SaveNoValidate, because a bookkeeping write must not fail on a rule meant for client requests.
Why so strict? Because the browser is not the only client. The admin UI, a stale PWA on a tablet that did not update, a future report with expand=product: each of them would quietly bypass a rule that only exists in the UI. The acceptance test (scripts/acceptance.sh, pnpm test:pb) therefore runs against the real API, not against mocked functions. It covers that table, including “change the price, the old receipt total must not move”.
Checkout Is One Transaction
Tapping products on the sales screen touches nothing on the server. The receipt exists only on the device until “Kassieren”. Then the whole receipt goes out as a single PocketBase batch: create the order, create every line, mark it paid. One request, one transaction (src/lib/till.svelte.ts:372):
const id = recordId();
const batch = pb.createBatch();
batch.collection('orders').create({ id, event: event.id, ...(stand ? { stand } : {}) });
for (const line of this.lines) {
batch.collection('sales').create({
order: id,
product: line.productId,
qty: line.qty,
unit_price_rappen: line.unitPriceRappen,
extras: line.extras.map((extra) => extra.id)
});
}
batch.collection('orders').update(id, {
payment_method: method,
kitchen_status: assignedTo ? 'arbeit' : 'offen'
});
const results = await batch.send();Two details carry the design:
- All or nothing. If a line fails validation, nothing is booked. There is no half receipt the kitchen could see.
- The client picks the id up front. If the response is lost on a flaky market Wi-Fi, the till does not retry blindly. It looks the receipt up under its own id first. Booked is booked, no double receipt.
The receipt number is allocated in the same transaction, on the server, as MAX(seq) + 1 for the evening (pb/rules/rules.go:434). Two tills booking in the same second cannot draw the same number, and a deleted receipt cannot hand its number out twice. The guest hears a shorter form: ((seq - 1) % 99) + 1, so the numbers called across the square stay between 1 and 99 while the internal sequence stays unique for the CSV and for the “was that mine?” question the next day.
The Kitchen Cannot Double-Take
The kitchen tablet shows paid receipts and lets a cook take one, finish it and hand it out. The state machine is deliberately one-directional (pb/rules/kitchen.go):
Every other transition answers 409. The interesting one is arbeit → arbeit by somebody else: that is the second tablet grabbing a receipt it still saw as open. The server has to decide, so onOrderUpdate wraps the whole update in a transaction and reads the stored receipt inside it (pb/rules/rules.go:317):
func onOrderUpdate(e *core.RecordRequestEvent) error {
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
return orderUpdateInTx(e)
})
}
PocketBase keeps a single write connection, so transactions run one after another and two concurrent updates see each other. A collection rule would be checked before the save, but not atomically with it. The acceptance test fires two grabs in parallel and expects exactly one 200 and one 409.
The kitchen role may change kitchen_status and nothing else. Payment method, void flags and SumUp fields are reset to the stored values for a kitchen login before the transition is evaluated (pb/rules/kitchen.go:36). Prices are sent to the kitchen screen but not rendered: hiding them behind a view without a read right on orders would have cost the live updates, since PocketBase only emits real-time events for real records. For a stand with two people, “not displayed” is enough.
Stands, Shifts and the Cash Count
Working at an event is a shift: a row in shifts with the person, the device, the event, the station (till or kitchen) and the stand. A till shift is valid immediately; a kitchen shift waits for the boss to confirm, which keeps kitchen people from granting themselves access. The server refuses a receipt without an active till shift, so “join the event first” is a rule, not a request. Several events may be open at the same time, and a device knows where it works through its shift.
Since October an event has one or more stands: a point of sale with its own cash drawer, its own sold-out list and its own kitchen. Products, extras and guest numbers stay shared. The cash count per stand is computed on the client from the evening’s receipts, not in a SQL view (src/lib/cash.ts): expected = opening float + cash takings, counted is entered by the boss, the difference is what the evening report shows. The receipts are loaded on that page anyway, and a pure function is testable.
Card Payments Without a Cloud Reader
Card payments go through SumUp. SumUp itself labels the Payment Switch, the hand-off to their app via a URL scheme, as legacy and recommends the Cloud API for web-based POS systems. Kasse uses the switch anyway, because the Cloud API only drives cloud-connected Solo readers, and at the stand we have an old Bluetooth reader that talks to the phone, plus Tap to Pay in the SumUp app. Both paths go through the app.
So the till builds a sumupmerchant://pay/1.0 URL with the amount, a foreign transaction id and a callback back to /sumup?order=<id> (src/lib/sumup.ts:15), the SumUp app takes the payment, and the callback lands in the PWA where the result is written to the receipt. The receipt is marked karte with sumup_status: unknown at checkout and confirmed afterwards; a confirmed card payment can no longer be changed to cash.
The known cost: returning from the SumUp app into an installed PWA on iOS is fragile, a long-standing issue in SumUp’s own tracker. The day we buy a Solo reader, the whole hand-off disappears in favour of two server routes, one to start the checkout and one to receive the webhook. That is a reason for the next hardware purchase, not a reason to rebuild now.
Offline: the Device Knows, the Socket Does Not
A real-time onDisconnect is not an offline signal. Without server pings the browser notices a dead socket minutes later. Kasse therefore shows a “Kein Netz” bar driven by navigator.onLine on every screen (src/lib/network.svelte.ts), and the kitchen additionally probes /api/health and resubscribes actively when the connection returns. After an interruption every subscribed view reloads once, so whatever happened in the gap arrives.
The service worker precaches the app shell and serves it offline, and it never caches the API (src/service-worker.ts:29):
registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkOnly());Booking a receipt without network is not possible yet. The batch is the right unit for it, one receipt equals one package to replay, and that outbox is the next item on the list.
Trash, Not Delete
An evening is never deleted through the API (events.deleteRule = nil). The boss can move a closed evening to the trash through a dedicated route that asks for the password again; a cron purges it after seven days together with its receipts, lines and shifts (pb/rules/trash.go). Three details made the simple version better:
- Hidden by read rule, not by query.
deleted_at = ''sits in the list and view rules ofevents,orders,salesandshifts. A forgotten filter in a new page or in a real-time subscription cannot show a trashed evening. The trash page reads past the rules through its own route. - The second door is closed. With
updateRule = chef, a boss token could setdeleted_atdirectly. The update hook refuses that; only the route may mark, and it saves withapp.Save, which skips the request hooks. Restoring is allowed for everyone, it cannot break anything. - Rate limit in the process. Five attempts per minute and account or IP, otherwise that route would be the place to brute-force the boss password.
Data Model
The schema exists only as Go migrations in pb/pb_migrations/. Nothing is clicked together in the admin UI, otherwise the container could not be rebuilt reproducibly. Statistics come from SQL view collections (event_stats, event_product_stats, product_stats, season_stats), computed on the server and cached in the client for five minutes with an explicit forget() after every write (src/lib/stats.svelte.ts). The old in-browser calculation survives as an oracle: an acceptance test checks that SQL and JavaScript produce the same numbers.
One migration lesson worth its own line: Go treats every file ending in _test.go as a test file. A migration named 1791260000_event_test.go compiled fine, was silently left out of the binary, and the field never appeared. Migration files must never end in _test.go.
Deployment
One image, built locally and shipped over SSH, no registry (production/docker-deploy-bundled.sh): back up the database, build the SvelteKit output and the Go binary, docker save, scp, docker load, swap the container through Compose, check /api/health, keep the current image plus one backup.
PocketBase serves the SPA from pb_public with the index.html fallback that adapter-static writes, so client-side routes work on reload (pb/main.go:57). The data directory is one volume mount with the SQLite file; nightly backups are SQLite .backup copies plus PocketBase’s own backup rotation.
Access is a PocketBase login per account, not the Authentik gate the rest of my homelab uses. With forward auth, the API would be open to anyone with a browser session and the till would only work behind the proxy. With its own login the protection hangs on the database itself, at the price of a login at the stand. That was the one blocking decision: the rules are in the first migration, and changing them later means migrating.
What Went Wrong
- The new-order sound was bound to
kitchen_status === 'offen'. In the assignment mode the till books a receipt straight to a cook, so it reaches the kitchen asarbeitand never asoffen: the sound was silent in the most used mode. Lesson: a signal tied to a state must be checked against every operating mode. crypto.randomUUIDonly exists in secure contexts. Overhttp://192.168.1.x:5176the device id threw before any request left the phone, with no error in the server log. The test walkthrough ran over127.0.0.1and saw nothing.- PocketBase’s SDK cancels a running request when a second one arrives with the same key, and the default key is method plus path. A store that loads at startup must use its own
requestKey, or it stays quietly empty. - PocketBase created
userswith an emptycreateRule: anyone could register and, being “logged in”, read every receipt. Now accounts are created by the superuser only, and a hook pins role and verified flag.
Summary
| Concern | Approach |
|---|---|
| Money | integer Rappen; price and name snapshotted into the sales line by a hook |
| Checkout | one PocketBase batch = one transaction; client-chosen id for safe retries |
| Receipt numbers | MAX(seq)+1 in the transaction; guests hear 1–99 |
| Kitchen | one-way state machine; RunInTransaction on the single write connection, 409 on the second grab |
| Rules | Go hooks for everything atomic or derived; collection rules for visibility; acceptance tests against the real API |
| Payments | SumUp app hand-off; Cloud API once a Solo reader exists |
| Offline | app shell from the service worker, API never cached, device-side detection; outbox pending |
| Deployment | one container on Unraid, PocketBase serves the SPA, SQLite on one volume |
Kasse is small on purpose: ten screens, five collections, one container. The engineering went into the places where a till must not be wrong, and all of those places are on the server.
This is the first post in the Kasse series. A follow-up will cover the receipt printer and the offline outbox once both are in production.