J. Castillo
Language
← Back to all work

loklflow

A system for running a whole restaurant: order taking, kitchen display, floor plan, inventory, cash close-out and per-role permissions.

Status
Deployed · closed behind loginDeployed and running, no demo credentials published
When
May — August 2026
My role
Design, backend, frontend, database and infrastructure

Architecture

The server — NestJS and PostgreSQL — runs inside the premises on a UPS, and the till, the waiters and the kitchen talk to it over the internal WiFi. The cloud only takes the remote dashboard and the backup.

  • One monorepo with two applications: the API in NestJS and the interface in Next.js.
  • Orders and table state travel live over WebSocket, with no screen reload.
  • The uuid the device generates is the primary key of the order and of its lines, so replaying an operation returns the state instead of duplicating the bill.
  • With the API down, the three operating screens keep working against a queue on the device itself. Taking payment and opening a till shift still require the network, on purpose.

Backend NestJS 11 · TypeORM · Socket.ioData PostgreSQL 16 · versioned migrationsFrontend Next.js 16 · React 19 · Tailwind 4 · ZustandTesting Jest · Vitest · supertestInfrastructure Docker · GitHub Actions

What I take away

In a system that has to survive without a network, the half you cannot add afterwards is the server half. Having a replayed operation return the state instead of duplicating the charge had to exist before the queue on the device did: you cannot get there in the other order, because by then the double charges are already in the database.

The full case — the decisions, the evidence and what is missing

In a restaurant in Panama City the internet goes down. Not always and not for long, but it goes down at 8:40 on a Friday with fourteen tables occupied. A cloud-hosted point of sale stops taking payment at that moment; one hosted on the premises does not.

So this system’s server runs inside the establishment, on a UPS, and the clients — till, waiters, kitchen — talk to it over the internal WiFi. The cloud is left with the two things that genuinely need it: the owner’s remote dashboard and the backup. That topology decision explains almost every other one, including why the instance I run at loklflow.juank.tech is an exception to it and not the way the system is meant to be installed.

The server goes on the premises, not in the cloud

What it costA real install means a machine in a kitchen; the remote instance is the exception

Situation

Taking an order and charging for it are the two operations that cannot depend on the internet provider having a good day.

The decision

The server — NestJS and PostgreSQL — runs on the premises, on a UPS, and the devices connect over the internal network. The cloud only receives the remote dashboard and the backup.

What I rejected

A cloud API with the venue’s network as a thin client, which is the default architecture and the one I would have picked out of habit. It loses at the only moment that matters: when the link drops, a thin client has nothing to do. I also rejected a native app per device, which would solve the same thing in exchange for three codebases and an app store.

The consequence

The internet stops being a dependency for operating. The cost is large and worth stating: a real deployment means putting a machine in a kitchen, so what I keep running at loklflow.juank.tech is a remote instance, an exception made to show the system. And that exception is the only deployment of this system that depends on its link for everything and not just for taking payment.

The order number comes from a Postgres sequence

What it costNumbers can jump if a transaction aborts

Situation

Every order carries a short readable number that the waiter calls out in the kitchen. It has to be unique and must not jump around oddly.

What was breaking

It was computed as MAX(order_number) + 1. Eight simultaneous creations returned 500: several read the same maximum, the first won, and the others’ retries collided with each other again. The migration’s own comment says that with two waiters taking orders at once, the order was lost.

The decision

A Postgres sequence queried with nextval. Numbering is atomic and there is no retry code.

What I rejected

A SELECT … FOR UPDATE over the last order, which serialises correctly and would turn every order creation into a wait behind the previous one — precisely at peak hour, which is when the problem shows up. I did not measure it: I rejected it for what I expect it to do, not for a number. And a retry with random backoff over the MAX, which lowers the probability without removing it.

The consequence

Eight simultaneous creations produce eight distinct numbers, and an integration test asserts it. The sequence was declared deliberately without SET DEFAULT and without OWNED BY, so TypeORM’s automatic synchronisation in development cannot fight it. The cost is that numbers can jump if a transaction aborts: nextval is not returned. For an operational counter that is acceptable; for a tax receipt number it would not be.

The device's uuid is the order's primary key

What it costAn extra read before every write; the client decides the id

Situation

If a device is going to be able to resend an order that may already have arrived, the server has to be able to recognise it as the same order and not as a new one.

The decision

The device generates the uuid and that uuid is the primary key of the order and of its line items. Before writing, the service looks up whether it already exists and, if it does, returns the existing one.

What I rejected

A server-generated id plus a client_request_id column, which works but forces id translation at sync time. And — this is the important one — I rejected catching the unique-key violation and treating it as “already existed”. With TypeORM that does not work: save() on an entity with an existing primary key does not fail, it does an UPDATE. It would have silently overwritten the order and, through the cascade, its line items too. The exception I was waiting for never arrives.

The consequence

Five concurrent replays of the same uuid leave exactly one row, and a test counts it with count(*). The cost is an extra read before every write, and accepting that the client decides the identifier — which is right here and would be unacceptable if the id had to be secret or unpredictable.

Payment accepts an idempotency key, unique but nullable

What it costAn opt-in guarantee per client, not a universal one

Situation

A partial payment gets recorded several times against the same tab: someone pays 40 of 100, then 60. A resend cannot turn into an extra charge.

What was breaking

The comment on the migration that fixed it puts it plainly. It is in Spanish because it comes from a real file, and it is quoted here untranslated for that reason:

Hoy un pago completo repetido se rechaza de rebote —la cuenta ya está cerrada—, pero un pago parcial repetido pasa entero y suma: dos clics dejan 60 cobrados sobre una cuenta de 100. Con una cola de reintentos, sistemático.

apps/api/src/database/migrations/1785449564000-OrderNumberSequenceAndIdempotency.ts:14-16

A repeated full payment bounces off the closed tab, but a repeated partial payment goes straight through and adds up: two clicks leave 60 charged against a tab of 100. That last line is the one that matters for the section further down: with an automatic retry queue, the double charge stops being an accident and becomes the normal behaviour.

The decision

payments.client_request_id, with a unique index. A client that wants the guarantee sends it; one that does not, does not.

What I rejected

Making the column mandatory, which is the clean answer and breaks the wired point of sale, which sends no key because it does not need one. And a second idempotency-key table, which is the textbook solution and adds a write and a table for a case the index already solves.

The consequence

The index is unique but allows nulls, because Postgres permits multiple nulls in a unique index. That gives an opt-in guarantee per client without a second table. And there is a test asserting that the un-keyed case does produce two distinct charges, because that is the correct behaviour and not a gap: two genuine partial payments are two payments.

One open till shift per cashier, guaranteed by the index

What it costThe rule lives in two places and the lower one wins

Situation

A cashier opens a shift, takes payments during their run, and closes with a cash count. That entire count depends on there being exactly one open shift.

What was breaking

The check lived in the service: read whether there is an open shift, and if not, create one. Two tabs or a double click open two shifts. Payments split across both and neither balances.

The decision

A partial unique index: unique on the user, but only WHERE status = 'open'. The index violation is translated into the same 400 the normal check would return.

What I rejected

Leaving it in the service with a transaction and a row lock on the user, which works and puts the guarantee in the code that reads it rather than in the structure that holds it. A full unique index would not do either: it would prevent the next day’s second shift.

The consequence

The race stopped existing, and because the violation is translated it never surfaces as a 500 — the cashier sees the same clear message as before. The service check stays for the normal case; the index is the net underneath. The cost is that the rule now lives in two places and you have to remember that the lower one is authoritative.

Fig. 1The trap is in the middle band: save() with an existing primary key does not throw, it updates. That is why the lookup happens before the write and not inside a catch.

Sessions for a dining room, not for an office

Access control is deny-by-default: JwtAuthGuard and PermissionsGuard are registered as global guards, so every endpoint needs an explicit @RequirePermissions('module:action') or a @Public(). That is 30 permissions across 5 roles, with per-role discount ceilings of 100, 50, 10, 0 and 0%.

There are two ways in, because a dining room is not an office: email and password for administration, and a four-digit PIN for floor staff, who are not going to type a long password into a tablet every time. The lifetimes differ on purpose — 15 minutes of access and 7 days of refresh for email, 4 hours and 12 hours for the PIN. And refresh propagates the original login method: pinning it to 'email' downgraded a PIN session to 15 minutes and falsified the audit log, which was the more serious of the two problems. Every refresh issues a unique identifier, because two logins in the same second produced a byte-identical token against a unique column.

The two halves of offline

A system that survives losing the network has two halves. The first is that the server knows how to receive the same operation twice without charging for it twice. The second is that the client knows how to store the operation while there is no server and resend it when it comes back. They were built in that order on purpose, and the order is the decision.

The first half is the one you cannot add later. An order’s primary key is the uuid the device generates, so a resend does not have to translate identifiers: it is the same row. Payments accept an idempotency key with a unique index. The order number comes from a sequence. Eight simultaneous creations produce eight distinct numbers; five replays of the same uuid produce one single row. Both are asserted by integration tests that run against a real PostgreSQL with the migrations applied from zero.

The second half was built on top of it. There is a queue of operations in IndexedDB, ordered per bill, with backing-off retries and a cross-tab lock so two tabs never resend the same thing. The three operating surfaces — floor, order pad and kitchen — read from the device itself and keep working with the API down. What does not enter the queue is money: taking payment and opening a till shift still require the network, on purpose. A queued charge is resent with nobody watching the result, and in a till shift that is a cash count that does not add up; I would rather the cashier sees “no network” than believes the charge went through.

A resend queue built on top of a server that is not idempotent fixes nothing: it turns an accidental double click into a systematic double charge. That is exactly the bug the idempotency migration documents, and that migration’s comment closes it in six words — «con una cola de reintentos, sistemático». Building the queue first would have produced a demo that works without WiFi and overcharges.

The server contract was fixed first and the client queue leaned on it. You cannot get there in the other order: by the time you want the contract, the double charges are already in the database.

loklflow's floor-plan editor. A grid canvas shows forty-one tables spread across four sectors outlined with dashed lines and one colour each: Frente, Interior, Barra and Terraza. Every table carries its global number and its seating capacity, and a coloured dot indicates its state. Above, filters by sector, a canvas zoom control and an edit-layout button.
Forty-one tables across four sectors. The table number is globally unique, not per sector: it is what the waiter calls out in the kitchen.
loklflow's kitchen display in three columns: pending with three orders, preparing with two, and ready with one. Each card shows the order number, the table, the elapsed time, and the order lines with their notes. The elapsed time is grey at 2, 4 and 7 minutes, amber at 11 and 18, and red at 24. Each column offers the action that belongs to it: start, mark ready, or mark delivered.
The timer colours itself: grey, amber at ten minutes, red at twenty. Each column only offers the transition that belongs to it, and the server rejects the rest: from pending an order goes to preparing or to cancelled, and nowhere else.

How you would check

The concurrency claims are the ones worth running, because they are the only ones you cannot verify by reading. pnpm --filter=api test:int boots the real application against a real PostgreSQL and applies the migrations from zero on every run — which also verifies that the schema builds by itself. Inside there is a test that fires eight simultaneous order creations and asserts the numbers are distinct, another that fires five replays of the same uuid and asserts count(*) = 1, and another that fires three simultaneous shift openings and asserts exactly one stays open.

On top of that, two Playwright suites run in a real browser — eight files under apps/web/e2e/ — and two of them go straight at this: offline-sync.spec.ts and service-worker.spec.ts.

The rest is readable: the global guards are in the root module, the 30 permissions in a seed file, and the partial index in its migration.

Money still requires the network

The offline queue covers service — taking an order, advancing it, seeing it in the kitchen — and leaves out taking payment and opening a till shift. That is deliberate and not an outstanding item: a queued charge is resent with nobody watching the result, and in a till shift that is a cash count that does not add up.

What it would cost to close is not the queue, which already exists. It is deciding what the system does when a queued charge fails on resend and the customer has already left: who absorbs the difference and who finds out. I do not have that answer, and until I do, taking payment keeps asking for the network.

Redis is declared and unused

It is ioredis, in the API’s production dependencies, a service in docker-compose.yml, and a config file the root module loads, and there is not one line that connects to it. The rate limiter uses the in-memory store, which is the right call while there is a single API process inside the venue. It is reserved for the Socket.io adapter and for the limiter’s store the day there is more than one.

I say so because a compose file with Redis in it implies an architecture that does not exist yet, and I would rather say it myself than have someone notice it reading the repository.

It is single-establishment

There is no tenant_id in any table, and the business configuration is a single-row table. The instance running today serves one venue, which fits the topology: if the server is inside the restaurant, there are not two restaurants on that server.

Adding multi-tenancy later touches all 30 tables and touches every query. It is a decision I have not made, not one I have made in favour of.

There are no background jobs

The shift cash count and the reports are computed when someone asks for them. With one venue and one shift per cashier that is correct; with twenty venues it would stop being. Nothing runs outside the cycle of a request: no job queue, no cron, no separate process.