Simplicity runs the registers, card payments, and live sales reporting for multi-vendor retail events. I designed it, built it, deploy it, and get the call when a register goes down mid-event.
01The result,
stated carefully
Before the checkout redesign, customers at the largest event reported standing in line for over an hour. They now clear the same line in roughly twenty minutes. Against that, the organizers measured event sales up about 30% and register wait times down about 65%.
Those percentages are the organizers' figures, and I can't cleanly separate the redesign from the events' own growth. I'd defend the mechanism rather than the number: hour-long lines produced returns, because people had time to reconsider what they were holding, and organizers had lost repeat customers to the wait. Both reversed after the redesign, and some of those customers came back specifically because they heard the lines were better.
The rest of this page is about the system underneath that, and the places where I traded something away to get it.
02Architecture
Events happen in convention halls and fairgrounds. The wifi is temporary, the uplink is shared, and the busiest ninety minutes of the year are also the minutes most likely to drop. Every structural decision below follows from that: the register has to keep selling when the network doesn't.
Two details in that picture are worth naming. First, the register cannot trust
navigator.onLine — it only reports that a network interface is up,
which at a venue is nearly always true and nearly always insufficient. A separate
HEAD /api/health every ten seconds decides whether the server is
actually reachable, and that's what drives the offline indicator and the sync loop.
Second, logs from a payments application are a liability if you ship them raw. Everything passes through a redaction layer before it leaves the process, so card data and credentials never reach Loki in the first place rather than being scrubbed after the fact.
03The migration
Simplicity started as a JavaFX/Kotlin desktop application talking to a Postgres instance on a server physically present at the venue. It worked. It also put a hard ceiling on the product.
I had two clients at the time and their events were spaced weeks apart, which is the only reason this was tractable. The two systems ran in parallel, but never against the same database — the web platform had its own Postgres from day one, and I wrote migration scripts to move each client's history across. Each client was cut over in the gap between their events, never during one. No event ever ran half on one system and half on the other.
Refusing a shared database is what made this safe. There was no dual-write path to keep consistent and no window where a sale could land in one system and not the other. The cost was that during the parallel period the two systems' data genuinely diverged until the scripts ran, so the migration had to be re-runnable and idempotent rather than a one-shot.
Websockets, not polling. Live vendor sales are polled — thirty seconds on most views, ten on the register, sixty on the heavier reports. It's simple and it survives flaky networks, but it's a pile of requests to show numbers that mostly haven't changed, and "live" means "up to thirty seconds stale." I'd push instead of poll, and keep polling as the fallback rather than the default.
Offline-first from the start. I built the web platform online-first and retrofitted offline behaviour once real venues taught me what their networks were like. That retrofit is the source of the ugliest constraint in the system: a sale exists only in one browser's IndexedDB for a second or more, so no server-side code can look it up while the customer is still standing there. Designing for it from the beginning would have cost me weeks and saved me more.
04Three decisions
and their bills
The normal path runs the charge through the application server, which holds the merchant credentials. If that request never reaches the server, the register can reach the gateway a second way, over infrastructure that doesn't depend on the app being up. The card itself is never in either path — the pinpad is on the network in its own right, the gateway prompts it directly, and the reader hands back a result. Nothing card-shaped passes through Simplicity.
The scoping is the part I care about. The fallback fires only when the server was never reached. If the server answered and the gateway declined the card, the error surfaces to the cashier and the request stops there — no out-of-band retry, no chance of a decline becoming a double charge. Getting that condition wrong is how you charge a customer twice, so it's a single explicit flag rather than a catch-all.
A second path to a payment gateway is a second thing to secure, and it is necessarily less locked down than the one that runs entirely server-side. That widened surface buys availability, and I decided availability was worth it: the alternative is a register that can't take cards during an outage, at an event that happens one weekend a year and won't be rescheduled.
It's the part of the system I'd hand a security reviewer first, and the part with a standing plan to tighten — short-lived, narrowly scoped credentials issued ahead of each event rather than anything long-lived. I'm happy to walk through the specifics in an interview; they don't belong on a public page.
Sales go into IndexedDB and sync in the background; the POS keeps a queue of unsynced transactions and retries until each one lands. That much is ordinary offline-first work.
The hard case is a charge that succeeds at the gateway while the device never learns it did — the reader approves, the network drops, the cashier closes the app. The money moved and the system has no record of it. So every charge carries an order id generated on the client before the request goes out, which means the charge can be found at NMI later by that id. A charge abandoned before confirmation is recorded as an incident; if the server is unreachable when that happens, the incident queues locally alongside the sale and flushes when connectivity returns.
For a second or more, a sale exists on exactly one device and nowhere else. Nothing server-side can look it up by id during that window, which quietly constrains every feature that wants to read a sale while the customer is still at the counter — gift card linking had to be designed around it. That constraint is a direct consequence of retrofitting offline support onto an online-first design.
Vendor payouts, sales by category, per-item time series: eleven .sql files,
executed through $queryRawTyped so the result shapes are typed against the
schema at generate time rather than cast by hand. Results are cached in Valkey, keyed by
report, user, organization, location, and parameters.
These are multi-level aggregates over transaction items, and expressing them through the ORM produced queries I couldn't reason about or tune — which matters when hundreds of vendors are refreshing their own numbers every thirty seconds for eight hours straight. Writing the SQL directly meant I could read the plan and fix it.
The SQL sits outside the ORM's migration awareness. Rename a column and nothing complains until a separate generate step runs; forgetting to run it after editing a query is a real and recurring way to break the build, and it's a step a second engineer would have to learn from a README rather than from the tooling.
The cache carries its own cost: a five-minute TTL means a vendor's dashboard can lag their most recent sale. For a vendor deciding whether to restock a table, that's fine. It would not be fine for anything a customer sees, so it isn't used there.
05The product
Captured from the development environment against seed data, so the item names and dollar figures are placeholders — the screens and the code paths behind them are the real ones. Client data stays out of this page.

The register. Every line item carries a vendor number, because each sale has to split to the right vendor's payout when the event settles. The Food? column drives tax treatment per line rather than per sale. Totals are computed on the device, and the transaction is in IndexedDB before any of it is sent.

What a vendor sees. Their own line items over a date range, totalled before and after the organizer's commission. Combine Items rolls duplicates into one row; Export hands over the same rows as CSV, which is what most vendors actually want at the end of a day.

Organization sales performance. One of the eleven hand-written SQL reports, rendered with Recharts and served through the Valkey cache. Grouping goes down to the hour, disabled past a seven-day window.