WarungKasir
Point-of-sale app for small food stalls that keeps selling with the internet down and syncs every sale exactly once when it comes back. Prints receipts to Bluetooth thermal printers from the browser.
In daily use at a food stall since October 2026
Next.js, TypeScript, Supabase, Postgres RLS, IndexedDB, PWA, Playwright, pgTAP
The problem
A small food stall in Indonesia tracks its sales on paper or not at all. The owner wants to know what sold and how much came in today; the cashiers want something faster than a notebook and a calculator. The point-of-sale apps on the market want a monthly fee, a tablet, or a connection that never drops, and a roadside warung has none of those.
Constraints
- No running cost. No server to rent: a static site on Cloudflare Pages and the Supabase free tier.
- A cheap Android phone is the cashier's only device. Everything is designed for a 360-pixel-wide screen and installs as a PWA.
- The signal drops. A sale must never be lost and never be counted twice.
- Two roles with different trust. The owner sees everything. A cashier sells and sees their own history, and the database enforces that, not only the screen.




How it works
Checkout does exactly one thing, online or offline: it writes the sale to an outbox in IndexedDB with strict durability, so the sale survives a crash or a dead battery from the moment the success screen appears. A background sync agent sends the outbox to the server later. There is no separate "offline mode", because there is only one path.
Each sale carries a UUID generated on the phone. The outbox write and the server's create_transaction function are both idempotent on that ID, so a double tap, a retry after a timeout, or a response lost on the way back can never turn one sale into two.
When a sync fails, the agent decides whether trying again can help:
- Transient (no network, timeout, server error, expired token): keep the sale pending and retry with backoff.
- Permanent (the server rejected the sale itself): keep it on the phone, mark it failed, and let a person retry it.
- Session (the account was signed out on the server): sign in again first. The outbox is never touched.
The server checks every line's arithmetic and the total, refuses products from another store, and refuses a sale ID that belongs to another cashier. It deliberately accepts the price the phone had cached, because an offline sale may legitimately use the menu from before a price change. That trade-off is written down as an accepted risk rather than hidden.
Authorization lives in Postgres row-level security. Creating, deactivating and resetting cashier accounts needs Supabase's privileged Auth API, so it goes through an Edge Function that checks the caller is the store's active owner inside its own code, and rolls back a half-created account if a later step fails.

Testing
I wrote the product requirements, technical specification, database design, threat model and test plan before writing code, and kept them current as the app changed.
| Layer | Tooling | Size |
|---|---|---|
| Unit and component | Vitest, Testing Library | 1,094 tests |
| Database: schema, grants, RLS, the sale function | pgTAP | 365 assertions |
| Edge Function over real HTTP | Vitest on the local stack | 21 tests |
| End to end on three phone widths, desktop Chrome, Edge, the production build, and offline | Playwright | 726 runs |
Continuous integration replays every migration on an empty database and runs the pgTAP suite on each push.
Results
- In daily use at a food stall since October 2026.
- Tested on a real phone: install, sell offline, kill the app, reconnect, and the sale syncs once.
- Runs at zero monthly cost.
What I'd build next
Stock levels that go down with each sale, QRIS payments, and support for an owner with more than one stall.