TL;DR

  • QuickPOS is a point-of-sale system for small food businesses: a register, a kitchen display, cash shifts with PIN sign-in, sales history with voids and refunds, inventory, and daily reports
  • It runs in the browser. The API is Hono and tRPC on Cloudflare Workers, the data is in Turso (SQLite), and the web app is a React SPA
  • Checkout is safe to retry. If the connection drops halfway through a charge, trying again returns the first result instead of ringing up the sale twice
  • Permissions, stock changes and optional features each have one shared definition that the web app and the API both use
  • I'm preparing it for a pilot with one counter-service food merchant. The code is private, so this post shows it through screenshots, a demo and diagrams

A cashier picks egg and add-on options for a Tapsilog, takes ₱500 cash and sees the change, and the order shows up on the kitchen display

A cashier picks egg and add-on options for a Tapsilog, takes ₱500 cash and sees the change, and the order shows up on the kitchen display. The store, staff and menu are made up for the demo.

Why I built it

Small food stalls and counter-service shops in the Philippines often run on a notebook, a calculator and a cash box. The POS systems built for them are usually priced and designed for bigger restaurants, and many of them assume things that don't hold at a small counter: that prices exclude tax, that the Internet is always up, or that one person does everything.

I wanted to build one that fits how these shops actually work, and to build it the way I'd build software I'm paid to maintain. That second goal shaped most of the decisions in this post.

What it does

The core is the part a merchant uses every day:

Area What it covers
Register Cart, add-ons and modifiers, cash and other payments, receipts
Kitchen A display that shows each ticket from pending to ready
Shifts Open with a PIN and a starting cash count, close and count the drawer
Sales history Order list, voids and refunds
Daily totals Sales summary, shift report, payment types
Access Owner, admin and cashier roles, staff PINs

Around that core are optional modules: tables, inventory and purchasing, customers and loyalty, time clock and payroll, advanced reports, and the audit log. Each one can be switched off, which matters more than it sounds. I'll come back to it.

The register with menu items on the left and a cart on the right, including a Tapsilog with "Sunny side up, Extra rice" and a total of ₱375.00

How it's built

One pnpm monorepo

One pnpm monorepo. The web app and the API both import the shared package, so a price rule, a permission or a module switch has one definition. The Rust coordinator is the start of offline mode and is paused until the pilot needs it.

It's my third attempt at the stack. The first version used TanStack Start with Postgres, the second moved the data to Convex, and in April 2026 I rebuilt it as a plain React SPA talking to a Hono and tRPC API on Cloudflare Workers, with Turso for storage. The SPA and tRPC split was the one that stuck: the API types flow into the web app, the Zod schemas live in one shared package, and Workers plus Turso cost close to nothing while there's one merchant.

The repository has about 975 unit and integration tests (Vitest) and 116 browser tests (Playwright) across the register, kitchen display, roles and module switches.

A charge must happen exactly once

The scariest bug a POS can have is charging twice, or losing a sale the customer already paid for. Both come from the same moment: the cashier taps Charge, the network is slow, nothing seems to happen, and they tap again.

The register makes one key per charge and keeps it until the charge succeeds or the cashier dismisses it

The register makes one key per charge and keeps it until the charge succeeds or the cashier dismisses it. A retry with the same key gets the stored answer back, so a slow network can't ring up a sale twice.

Every money-moving call (creating an order, completing it, and refunding it) goes through one wrapper, withIdempotency. The register makes a key for the charge and reuses it on every retry. The server stores the response under that key the first time, and any repeat with the same key and the same cart gets the stored response back. A repeat with the same key and a different cart is refused, because that's a bug, not a retry.

Two smaller decisions back this up. The web app never retries a money mutation on its own; a retry is always the cashier pressing Retry in the checkout dialog. And the server checks whether an order is already finalized before completing it, which covers the rare case of two truly simultaneous requests.

The checkout dialog with ₱500.00 cash tendered, ₱125.00 change due, and a Charge ₱375.00 button

Whose drawer is this money in?

At the end of a shift, the cashier counts the drawer and QuickPOS compares it with what it expected. That only means something if every sale lands in the right shift.

The client never says which shift a sale belongs to

The client never says which shift a sale belongs to. The server works it out from the register and the device that activated it, so a browser that only knows a register id can't put money in someone else's drawer.

The rule I settled on is that the client never names a shift. It sends the register and the device, and the server works out the open shift from those. A register is activated on one browser by an owner, so knowing a register's id isn't enough to put money in it.

PINs get the care you'd give passwords, because a four-to-six-digit PIN is easy to guess. They're hashed with PBKDF2, failed attempts are rate-limited per store, and every attempt is logged. The rate limit lives in Workers KV rather than in memory: a Worker runs as many separate isolates, and an in-memory counter would let someone spread guesses across them.

The Close Shift dialog: ₱4,000.00 expected in the drawer, ₱3,980 counted, a −₱20.00 difference flagged for review, and a PIN pad

Paid and cooked are different things

In a counter-service shop, customers usually pay first and wait for their food. My first version of orders had one status, so completing the payment also closed the order, and the ticket left the kitchen board before anyone had cooked it.

An order has two states that move on their own

An order has two states that move on their own. Before the split, paying for an order took it off the kitchen board, so a counter that takes payment first lost the ticket before anyone cooked it.

The fix was to give the order two states that move on their own. The cashier moves payment; the kitchen moves fulfillment. The kitchen can only move a ticket forward, and only Cancel closes both, because cancelling also has to put the stock back. A migration closed the tickets for old paid orders so the kitchen board didn't fill up with history.

The kitchen display with paid tickets marked New, Preparing and Ready, one with the order note "No onions on the adobo"

One rule, one place

By late 2026 the code had grown to about 85,000 lines of TypeScript, and I ran an architecture review over it. It found three rules that were each written in several places, and all three had already caused bugs.

Three rules that used to be written more than once

Three rules that used to be written more than once. Each one now lives in a single module that both sides call, and the old way of doing it is the thing the docs tell you not to do.

Permissions. The web app decided what to show, and the API decided what to allow, each with its own checks. They disagreed: some screens showed buttons the API then refused, and some hid things the API would have allowed. Now there's one access policy in the shared package, with 25 named capabilities like products.edit and reports.export. The API enforces it and the web app reads the same rule. A misspelled capability is a type error.

Stock. More than a dozen API routes wrote stock levels directly. Some skipped the movement history, so the history didn't add up to the stock. Products could exist with no stock row at all, and replacing a product's variants deleted the product's own stock. Now one module, the stock ledger, is the only code that writes stock, and it writes the new count and the reason for the change in the same transaction.

Optional features. A pilot merchant should see only what they use, but switching a feature off used to mean finding its links, routes and API procedures by hand. Now each module has one switch in the shared package. Turning it off hides the menu entry, redirects its pages, and makes the API refuse the procedures it owns. Nothing is deleted, so turning it back on is a one-line change.

Editing a custom Inventory Clerk role, with permissions grouped by area such as Point of Sale, Orders and Products

Decisions for a Philippine pilot

The module switches made the pilot scoping concrete. For a counter-service food merchant, I'm turning off tables, customers and loyalty, the time clock and payroll, and discounts for now. The kitchen display stays on.

Tax is off too, and the reason is specific to the Philippines. QuickPOS's tax rates add tax on top of the price, but prices here are shown VAT-inclusive, and most small merchants aren't VAT-registered. Adding tax at the register would charge customers more than the menu says.

I also wrote down the rules for while the pilot runs. New features are frozen, and only what blocks the merchant's day gets fixed. Correctness safeguards are never cut: accurate totals, authorization, duplicate-sale protection, and recovery from failed requests. And AI stays out of the checkout path, so a model outage can never stop a sale.

The orders list, where each order shows a payment status and a kitchen status, including one cancelled and one refunded order

What about brownouts?

Power and Internet outages are common enough here that a POS needs an answer for them. I've designed offline mode and built its core, but I've paused it until the pilot shows how much it's needed.

The offline design, for brownouts and outages of up to eight hours

The offline design, for brownouts and outages of up to eight hours. The cashier device keeps the record, the kitchen display follows it over the store's own Wi-Fi or a phone hotspot, and the cloud catches up later. The core and its tests exist; the Android shell doesn't yet.

The design has the cashier device act as the store's coordinator. It writes every sale to a local, signed, append-only event log before confirming it, and the kitchen display follows that log over the store's Wi-Fi or a phone hotspot. When the Internet comes back, the events sync to the cloud, and any conflict goes to a review list instead of overwriting data.

The core is a Rust crate of about 2,600 lines with 65 tests. It covers the event log in SQLite, Ed25519 signatures, the kitchen board rebuilt from events, QR pairing, and a LAN transport with TLS pinned to the cashier's certificate. The same test vectors run in Rust and TypeScript, so both sides agree on what a signed event looks like. The scope is deliberately narrow: no official receipts, no loyalty redemption and no menu changes while offline, and GCash or Maya payments are recorded as checked by the cashier but not confirmed.

What's next

The next step is the merchant visit. I'll watch how they work today and fill in the parts of the pilot plan I've left blank: which device they'll use, which modules they need, and what the first improvement should be. The goal is small on purpose: one merchant runs a normal day on QuickPOS, records sales, corrects mistakes, and reconciles the drawer at close.

If you run a small food business in the Philippines and want to try it, or you just want to talk about POS software, send me a message.

Originally published at rielj.xyz.