Skip to content

POS architecture

The nexis-omni-pos app is built to keep selling when the network is down. It rings, prices, and tenders sales fully offline, prints a receipt from locally computed totals, and durably replays each sale to the backend exactly once. This page orients you to how it is put together. The authoritative design lives next to the code, in nexis-omni-pos/docs/architecture/foundation-blueprint.md.

For the sync contract at a glance, start with the POS app overview. This page goes one level deeper into the layers.

The app is feature-first Flutter (lib/features/<feature>/{data,domain,presentation}) over a small set of framework-light primitives in lib/core/. Three pieces carry the offline-first weight:

Layer What it does
Local store (Drift / SQLite) One database per device. Sales, cash sessions, the sync outbox, and a disposable catalogue cache all live here. A sale is written before its receipt prints.
State (Riverpod) Providers derive off a single composition root. Dependency injection is a hand-rolled async bootstrap() that builds every singleton (database, stores, both networking clients, the sync engine) into one bag, wrapped in a ProviderScope.
Networking (Dio) Two HTTP clients: a main client that carries auth and refresh, and an interceptor-free client dedicated to token refresh so refresh can never recurse.

A single shared local database is deliberate, not incidental. Finalising a sale writes the order, its lines, line taxes, payments, and the outbox entry in one transaction before the receipt prints. That single-transaction commit is the foundation for never losing an acknowledged sale.

Tables are split by how precious they are:

  • Disposable master cache - branches, products, variants, tax rates, registers. Freely truncatable and rebuildable from the backend; the catalogue refresh is an atomic delete-all-then-insert.
  • Precious rows - the order aggregate, the sync outbox, cash sessions, and session key-value metadata. These are frozen history and are never dropped.

Money and rates are stored as decimal strings (never a floating-point column); enums are stored as PascalCase text matching the wire; timestamps are integer UTC epoch millis. Hard cascading foreign keys exist only within an aggregate (order to lines to taxes, session to movements). References from precious rows out to the master cache are loose, un-keyed id strings, so the cache can be rebuilt without touching frozen history.

A lease-guarded, single-flight run loop drains the outbox to POST /sync/orders. It is guarded by both an in-process lock and a single-row database lease with a short TTL, because single-process is an explicit invariant - there is no background isolate in this version.

The drain is ordered and careful:

  1. Acquire the lease, then probe reachability (a health check, since interface connectivity alone is only advisory).
  2. Claim a batch in FIFO order by the order’s UUIDv7 id, using an ownership-asserting update, then re-read only the rows it actually won rather than the pre-update id list.
  3. Post the batch, apply the per-item results in one transaction, and prune old settled rows.

A dirty bit ensures that a sale rung mid-run triggers exactly one extra drain, so nothing rung during a run is stranded.

Every synced item resolves to one of two shapes, and the distinction is what makes the queue safe:

  • Terminal - a definite business verdict. A success settles the item as synced. A deterministic rejection settles it for review; the engine never silently voids a money-collected sale. Token, permission, catalogue, and any unknown reject reason are routed to a manager rather than auto-resolved.
  • Transient - a transport failure or a 5xx. These are retried with full-jitter backoff, and an item that exhausts its retry budget (six attempts) is parked rather than retried forever.

The order id is a client-generated UUIDv7, minted once at ring time, and it is the same id and same content on every retry. The server upserts on that id, so any number of posts of the same (id, content) collapse to exactly one order and one stock deduction. This is what makes the classic lost-acknowledgement safe: if the server committed but the response was lost, the next boot re-posts, the server reports a duplicate, and the item settles as synced.

  • Money is a quoted decimal string on the wire (for example "118.00"), never a JSON number. The POS decode is lenient about scale ("118" equals "118.00" as a value) but rejects a bare number, since precision is already lost by the time it parses.
  • Enums are PascalCase strings in both directions.
  • The tenant plane sends a Tenant-ID header on every request, including anonymous login and refresh; a missing header is a 400 before auth even runs. (This is the tenant plane only - the admin plane is a different surface and never sends that header.)
  • Response bodies are camelCase; request bodies are PascalCase. The OpenAPI document is the ground truth for casing.

The POS reproduces the backend’s order math at the value level (per-component tax rounding, half-away-from-zero, discount then tax), so a replayed offline sale recomputes to the same totals on the server and does not false-conflict.

The blueprint in the POS repo is the canonical, section-by-section source - schema, money, auth, and the full sync state machine:

  • nexis-omni-pos/docs/architecture/foundation-blueprint.md - the authoritative design.
  • nexis-omni-pos/docs/architecture/verification-findings.md - the adversarial blockers folded into the design and why each invariant exists.

For how the two auth planes and the money-on-the-wire rules bind the POS to the rest of the system, see the API contract. For why the release build trusts only CA-valid certificates, see the deployment plan.