Files
nearle_pos/docs/integration-guide.md
Suriya 33b4337933 Publish under nearle/pos, add a health heartbeat, send the GST slab split
Three changes, all driven by what the back office turned out to need.

The broker is shared with the rider fleet on nearle/riders/…, so topics
move under nearle/pos/{locationid}/{terminal}/… — one ACL rule per
system, and it is obvious from a topic which one owns it. Store ID now
carries the back office's numeric location id; the tenant is resolved
from it server-side and never taken from the wire.

A till publishes a heartbeat every 30 seconds on its own topic. The Last
Will already answers "is it dead", which is not enough to run a hundred
shops on: the failure that costs money is a terminal that is connected,
selling, and quietly holding two hundred bills it has never uploaded. So
the beat carries queue depth, the age of the oldest thing waiting,
today's trading, and printer reachability. Not retained — the back
office holds it under a TTL, and a retained beat would leave an
unplugged till looking alive until something overwrote it.

Bills now carry tax_breakdown, the GST slab split the cart already
computes. A tax return is filed per slab, and recomputing the split
server-side would mean redoing the discount apportionment and getting
exactly the same answer — or else the filed figure stops matching the
paper the shopper was handed.

Docs rewritten against the real deployment: Eclipse Mosquitto 2.1.2, no
NATS anywhere reachable, no TLS, and a broker whose queue and autosave
defaults mean it must not be treated as durable storage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:47:24 +05:30

10 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. Mosquitto reachable, with a scoped account for the tills
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 — Mosquitto, with an account for the tills

The deployed broker is Eclipse Mosquitto 2.1.2 at 66.116.225.226:1883, already carrying nearle/riders/# and doormile/#. There is no NATS in this estate — the NATS servers that exist belong to other projects, on hosts whose ports are closed, and their configs expose no MQTT gateway.

Current config has allow_anonymous false and a password file, but no acl_file — so every authenticated user, including the admin account hardcoded in the rider APK, has full run of every topic. Add scoped accounts before a hundred tills start publishing takings:

mosquitto_passwd -b /mosquitto/config/passwd pos_terminal '<strong-pw>'
mosquitto_passwd -b /mosquitto/config/passwd pos_ingest   '<different-pw>'
# /mosquitto/config/acl, then add `acl_file /mosquitto/config/acl` to mosquitto.conf
user pos_terminal
topic write nearle/pos/+/+/order
topic write nearle/pos/+/+/customer
topic write nearle/pos/+/+/status
topic write nearle/pos/+/+/health
topic read  nearle/pos/+/+/ack
topic read  nearle/pos/+/+/command
topic read  nearle/pos/+/catalogue

user pos_ingest
topic read  nearle/pos/+/+/order
topic read  nearle/pos/+/+/customer
topic read  nearle/pos/+/+/health
topic write nearle/pos/+/+/ack
topic write nearle/pos/+/catalogue

user admin
topic readwrite nearle/riders/#
topic readwrite doormile/#

Prove it:

mosquitto_sub -h 66.116.225.226 -p 1883 -u pos_ingest -P '<pw>' \
  -t 'nearle/pos/#' -v &
mosquitto_pub -h 66.116.225.226 -p 1883 -u pos_terminal -P '<pw>' \
  -t nearle/pos/12/T0000/order -m 'hello'

The broker is a transport, not a ledger. max_queued_messages defaults to 1000 and autosave_interval to 30 minutes, so a long outage or a hard kill can drop queued messages. That costs nothing here — an undelivered batch is never acked, so the till keeps it and sends again — but only while nobody acknowledges on the broker's behalf.

TLS is not configured and 8883 is closed. Bills carry customer names and mobile numbers; worth adding a listener before rollout rather than after.

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 The numeric locationid. The tenant is resolved from it server-side
Transport MQTT
Broker host / port 66.116.225.226, port 1883, TLS off
Username / password The pos_terminal account from hop 1
Use TLS Off — 8883 is not configured on this broker yet

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

mosquitto_sub -h 66.116.225.226 -u pos_ingest -P '<pw>' -t 'nearle/pos/+/+/status' -v

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

mosquitto_sub -h 66.116.225.226 -u pos_ingest -P '<pw>' -t 'nearle/pos/+/+/order' -v

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 already built, in the Fiesta backend. See backend_fiesta/POS_TERMINAL_INGEST.md for how to turn it on; what follows is what it guarantees, so you can check it still holds if anyone changes it.

Set MQTT_URL and it subscribes to nearle/pos/+/+/{order,customer,health}, commits, and acknowledges. Bills land in pos_orders / pos_order_items, and the stock they consumed goes through the same productstocks ledger an app order uses.

Three rules the implementation is built around, and that any replacement must also keep:

Acknowledge from the consumer, after the database commit. Not from a 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.

A duplicate is accepted, not rejected. QoS 1 is at-least-once and a lost ack makes the terminal re-send the whole batch. Reporting those as failures would strand a day of takings on the till. Deduplication is a unique index on the till's UUID plus a Postgres advisory lock.

Read the store and terminal from the topic, never the body. A till that could name its own store in a payload could redirect another counter's acknowledgements.

Prove the ack path by hand before trusting the consumer:

# copy batch_id and the order id from the hop-3 output
mosquitto_pub -h 66.116.225.226 -u pos_ingest -P '<pw>' \
  -t 'nearle/pos/12/T4A9/ack' \
  -m '{"batch_id":"<paste>","accepted":["<paste-order-id>"]}'

The pill should flip to LIVE and the bill disappear from the queue.

Shopper registrations

A second uplink runs on nearle/pos/{loc}/{terminal}/customer, acked on the same topic by the same rules, and handled by the same consumer.

The id is a UUIDv5 over the shopper's normalised ten-digit mobile, so two tills registering the same person independently produce the same row. It is stored insert-if-absent — never an update, so a profile corrected at head office is not reverted by a terminal replaying an old capture. No loyalty figures travel upward: those are derived from the bill stream, which is idempotent and sees every counter.

mosquitto_sub -h 66.116.225.226 -u pos_ingest -P '<pw>' -t 'nearle/pos/+/+/customer' -v

Add a shopper on the terminal — no sale needed — and it should appear.

Terminal health

Every till publishes to nearle/pos/{loc}/{terminal}/health every 30 seconds. The consumer writes it to Redis as pos:terminal:{code} under a 90-second TTL, so a till that loses power ages off the board by itself. Read it back at GET /live/api/v1/pos/health/location?location_id=12.

Heartbeats are never acknowledged — a till that could be blocked by a busy dashboard would be a self-inflicted outage.

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_delta is load-bearing. A full snapshot withdraws every product it does not mention. Answer is_delta: true for a change set, or the first morning price change empties the shelf.
  • Send stock only 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:

mosquitto_pub -h 66.116.225.226 -u pos_ingest -P '<pw>' \
  -t 'nearle/pos/12/catalogue' -m '{"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 nearle/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

  • Add the broker ACL first (hop 1). Today every authenticated user is unrestricted on every topic, including the admin account hardcoded in the rider APK.
  • Back up nearle_pos.db on any terminal already trading. The schema goes to v8 on first launch and the migration is one-way. It also queues every shopper already on the terminal for upload, so expect one burst of registrations from each existing store — collapse those onto the mobile number.
  • Build per-ABI. flutter build apk --split-per-abi gives ~23MB per architecture instead of a 69MB universal APK — worth it over shop wifi.
  • Change the seed PINs. 4821 / 5093 / 6274 are 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.