Case study — sole engineer, 2022–present

Simplicity,
a point-of-sale
platform.

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.

15 Registers at once — most an event has needed
$800K Sales through a single event
3 orgs Clients, ~5 events a year each
100s Vendors watching their own sales live
1 Engineer — design through on-call

01The result,
stated carefully

Shorter lines were the whole point.

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%.

What I can and can't attribute

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

A venue's network is the thing you can't trust.

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.

Simplicity system architecture: browser registers with local storage, a Next.js application on DigitalOcean App Platform backed by managed Postgres and Valkey, the NMI payment gateway reached either through the server or through a separate fallback proxy, and an observability path into self-hosted Grafana. AT THE EVENT REGISTER Browser PWA, installed Service worker + IndexedDB Sale written locally first 15 at the busiest event VENDOR & ORGANIZER Browser, own sales only Polls every 30s DIGITALOCEAN APP PLATFORM NEXT.JS 15 · TYPESCRIPT App Router, route handlers Prisma ORM + TypedSQL NextAuth, per-org scoping Recharts reporting UI migrate deploy on release NMI POI READER At the event, on the internet Card never touches the app prompts the pinpad POSTGRES Managed · 35 models Hand-written report SQL VALKEY Managed · report cache 300s TTL, keyed per org NMI GATEWAY v4 Partner Key API FALLBACK PROXY Separate host, not the app server sync sales live reports card charge only when the app server is unreachable OBSERVABILITY OTel traces + Winston logs, redacted ALLOY Droplet · OTLP + Loki LOKI Log storage GRAFANA Self-hosted dashboards request / response fallback path telemetry
The register is a browser. Everything it needs to sell — the catalogue, the settings, the page itself — is precached, and the sale lands in IndexedDB before anything is sent. The pinpad is on the network in its own right, so the gateway prompts it directly and no card data crosses the application. The dashed path exists so that a dead app server doesn't also stop card payments.

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

Retiring the desktop app while the business kept selling on it.

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.

Single point
of failure
One local server, one venue, one power strip. If it went down, every register at the event went down with it, and there was no remote hand to fix it.
Data trapped
on site
Vendors could only see their sales by walking to the office machine. Hundreds of vendors now watch their own numbers from their booth all day, which is the single feature clients ask about first.
Per-machine
operations
Adding a register meant provisioning a laptop and installing a build on it. Shipping a fix meant touching every machine on the floor, during an event.

The cutover

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.

What I'd do differently

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

DECISION 01

Card payments have a second path to the gateway for when the app server is unreachable.

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.

The downside

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.

DECISION 02

The register writes locally first, and every charge carries an id minted before it leaves the device.

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.

The downside

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.

DECISION 03

Reports are hand-written SQL through Prisma TypedSQL, cached in Valkey — not ORM aggregates.

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 downside

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

What it looks like in the hall.

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 Simplicity register: a transaction items table with quantity, description, price, vendor number and line total, above subtotal, tax and total, with Add Item, Sell Gift Card and Complete Transaction buttons.

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.

A vendor's own sales view: date range filters, a Combine Items toggle, an export control, a table of their line items, and totals before and after commission.

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.

The organization sales performance report: a date range, a grouping selector set to Day, and a bar chart of revenue per 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.