sync-contract.md specifies what the back office must implement. It does not say how to get a terminal talking to one, which is the question anyone actually deploying this hits first. Five hops, in the order they should be proved: 1. NATS with the MQTT gateway on — config snippet, narrow per-topic permissions, and a file-backed JetStream stream, because a memory stream loses a shop's bills on restart after the terminal has been told they landed. 2. Pointing the terminal at it from Settings. 3. Watching a bill publish. 4. Consuming, committing, and acknowledging — with the table schema, an idempotent insert, and the rule that the ack comes from the consumer after the commit rather than from an ingest handler that merely queued the work. 5. The catalogue pull, and the mid-day push that triggers it. Each hop has a command that proves it works, because a failure at hop 4 looks identical to a failure at hop 2 from the terminal's side — it just keeps queueing. Plus a troubleshooting table mapping symptoms to causes (queue refilling with the same bills means the ack arrived after the 20s timeout; two terminals fighting for the connection means they share a client id), and a pre-rollout checklist: back up the database before the one-way v7 migration, build per-ABI to cut 69MB to ~23MB, change the seed PINs, turn TLS on. Every topic, timeout, query parameter and pill label in the guide was checked against the code rather than written from memory. README now points at both documents and states the default: the terminal runs against a local stub until it is configured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9.2 KiB
Connecting a terminal to your back office
Step-by-step wiring. The companion to sync-contract.md,
which specifies what the back office must implement — this one covers how to
get a terminal talking to it, and how to prove each hop works before moving to
the next.
Nothing here needs a rebuild. A terminal is pointed at a back office from Settings.
The five hops
1. NATS running with the MQTT gateway on
2. Terminal connects and shows LIVE
3. Terminal publishes a bill
4. Your consumer commits it and acks
5. Terminal marks it synced and stops re-sending
Do them in order. A failure at hop 4 looks identical to a failure at hop 2 from the terminal's side — it just keeps queueing — so proving each one saves a lot of guessing.
Hop 1 — NATS with MQTT enabled
MQTT needs JetStream turned on, because the gateway stores session state in it.
# nats.conf
jetstream {
store_dir: /var/lib/nats
max_file: 10Gi
}
mqtt {
port: 1883
# TLS in production. Bills carry customer names and mobile numbers.
# tls {
# cert_file: "/etc/nats/server.pem"
# key_file: "/etc/nats/server-key.pem"
# }
}
authorization {
users = [
{ user: "till", password: "…", permissions: {
publish: ["pos.*.*.order", "pos.*.*.status"]
subscribe: ["pos.*.*.ack", "pos.*.*.command", "pos.*.catalogue"]
}}
]
}
Note the permissions are deliberately narrow. A terminal has no business publishing to another terminal's ack topic.
Prove it:
nats sub 'pos.>' &
mosquitto_pub -h localhost -p 1883 -t pos/test/T0000/order -m 'hello'
# the nats sub should print it — that is the / → . mapping working
The stream must be file-backed. A memory stream loses a shop's bills on a server restart, and the terminal has already been told they landed.
nats stream add POS_ORDERS \
--subjects 'pos.*.*.order' \
--storage file \
--retention limits \
--max-age 720h
Hop 2 — Point the terminal at it
On the terminal: Settings → Connectivity & sync → Configure.
| Field | Value |
|---|---|
| Terminal name | What staff call this till, e.g. "Counter 2" |
| Store ID | Namespaces the shop on the broker. Must match across its tills |
| Transport | MQTT |
| Broker host / port | Your NATS host, 1883 plain or 8883 TLS |
| Username / password | From the authorization block above |
| Use TLS | On in production |
The dialog also shows a Device ID and a terminal code like T4A9. Neither is
editable. They are minted on first run and stay with the physical machine, which
is what keeps 100 terminals from colliding on topics, client ids and invoice
numbers. Note the code down — it is what a support call needs.
Credentials go to the OS keystore (Keychain / Credential Manager / Android Keystore), not into the database alongside the bills.
Prove it: the header pill switches from OFFLINE (SIM) to LIVE, and
nats sub 'pos.*.*.status'
should immediately show a retained presence record for the terminal. If it doesn't, the terminal never connected — check the broker log for an auth rejection before looking anywhere else.
Hop 3 — Ring a sale and watch it publish
nats sub 'pos.*.*.order'
Ring a bill on the terminal. Within a couple of seconds you should see the
envelope from sync-contract.md — a batch_id, the
store and terminal, and an orders array.
The header pill will show 1 QUEUED and stay there, because nothing has
acknowledged it yet. That is correct behaviour, not a fault.
Hop 4 — Consume, commit, acknowledge
This is the hop that matters. Two rules, both non-negotiable:
Acknowledge from the consumer, after the database commit. Not from an ingest handler that has merely queued the work. That ack is the terminal's only evidence, and it deletes its own copy seven days later on the strength of it.
Be idempotent on order.id. QoS 1 is at-least-once and a lost ack makes the
terminal re-send the whole batch. Every id is a UUID minted at the till, so this
costs you one unique index.
CREATE TABLE orders (
id UUID PRIMARY KEY, -- the terminal's order id
invoice_number TEXT NOT NULL,
store_id TEXT NOT NULL,
terminal_id TEXT NOT NULL,
cashier TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
subtotal NUMERIC(12,2) NOT NULL,
discount NUMERIC(12,2) NOT NULL,
tax NUMERIC(12,2) NOT NULL,
round_off NUMERIC(12,2) NOT NULL,
total NUMERIC(12,2) NOT NULL,
payload JSONB NOT NULL -- keep the raw envelope
);
-- Invoice numbers are unique per terminal, not globally. A till that was
-- replaced restarts its own series, so gaps are normal and do not mean
-- missing bills.
CREATE UNIQUE INDEX orders_invoice_per_terminal
ON orders (terminal_id, invoice_number);
Consumer shape:
for msg in subscribe("pos.*.*.order"):
batch = json.loads(msg.data)
accepted, rejected = [], {}
with db.transaction(): # one transaction for the batch
for order in batch["orders"]:
try:
db.execute("""
INSERT INTO orders (id, invoice_number, …, payload)
VALUES (%(id)s, %(invoice_number)s, …, %(payload)s)
ON CONFLICT (id) DO NOTHING
""", order)
accepted.append(order["id"])
except BusinessRuleError as e:
rejected[order["id"]] = str(e)
# Published only after the commit above has succeeded.
publish(f"pos.{batch['store_id']}.{batch['terminal_id']}.ack", json.dumps({
"batch_id": batch["batch_id"],
"accepted": accepted,
"rejected": rejected,
}))
ON CONFLICT DO NOTHING still counts as accepted — a redelivery of a bill you
already hold is a success, not a rejection.
Use rejected sparingly. Naming an id there halts the terminal's drain: it
stops retrying and waits for a person to press Sync. That is right for "this bill
is malformed" and wrong for "my database is having a bad minute" — for the
latter, don't ack at all and let the terminal back off and retry.
Prove it by hand before wiring the real consumer:
# copy batch_id and the order id from the hop-3 output
nats pub 'pos.store-01.T4A9.ack' \
'{"batch_id":"<paste>","accepted":["<paste-order-id>"]}'
The pill should flip to LIVE and the bill disappear from the queue.
Hop 5 — Catalogue down
The catalogue is a bulk pull over HTTP, not MQTT — a broker is the wrong shape for tens of thousands of rows. Set the Base URL in the same Configure dialog and implement:
GET {base}/catalogue?since={revision}&page={n}&store_id=…&terminal_id=…
Authorization: Bearer {apiKey}
Full field-by-field behaviour, including what happens when something is missing,
is in sync-contract.md. The two things
easiest to get wrong:
is_deltais load-bearing. A full snapshot withdraws every product it does not mention. Answeris_delta: truefor a change set, or the first morning price change empties the shelf.- Send
stockonly when you mean it. Any product in the payload gets its count overwritten with your figure, which predates sales the terminal has rung but not uploaded. The terminal replays those — but only for products the payload carried.
To push a change mid-day rather than waiting for the next pull:
nats pub 'pos.store-01.catalogue' '{"revision":"rev-8822"}'
Every terminal in that store pulls immediately.
Troubleshooting
| Symptom | Where to look |
|---|---|
Pill stuck on OFFLINE (SIM) |
Simulate offline is still on in Settings |
Pill shows LIVE, no presence on pos.*.*.status |
Terminal never connected — check broker auth logs |
| Bills publish, queue never empties | You are acking the wrong batch_id, or not acking at all |
| Queue empties then refills with the same bills | Ack arriving after ackTimeout (20s default) — the terminal gave up and re-sent |
| Two terminals fighting for the connection | They share a client id. Each device mints its own; check they have different terminal codes |
SYNC HALTED |
You named an id in rejected. The reason is on the pill tooltip and in Events |
| Duplicate rows server-side | No unique index on order.id. At-least-once delivery makes it mandatory |
| Shelf empties after a price change | You sent a delta with is_delta: false, or a stale stock |
The Events module on the terminal shows every sync attempt with its error, and per-bill state — start there before the broker logs.
Before a fleet rollout
- Back up
nearle_pos.dbon any terminal already trading. The schema goes to v7 on first launch and the migration is one-way. - Build per-ABI.
flutter build apk --split-per-abigives ~23MB per architecture instead of a 69MB universal APK — worth it over shop wifi. - Change the seed PINs.
4821/5093/6274are in the source. Every account is flagged to force a change at first sign-in, but a shop that dismisses it is running a published credential. - Turn TLS on. Bills carry customer names and mobile numbers.