# Connecting a terminal to your back office Step-by-step wiring. The companion to [`sync-contract.md`](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. ```conf # 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: ```bash 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. ```bash 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 ```bash 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 ```bash nats sub 'pos.*.*.order' ``` Ring a bill on the terminal. Within a couple of seconds you should see the envelope from [`sync-contract.md`](sync-contract.md#payloads) — 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. ```sql 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: ```python 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: ```bash # copy batch_id and the order id from the hop-3 output nats pub 'pos.store-01.T4A9.ack' \ '{"batch_id":"","accepted":[""]}' ``` 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`](sync-contract.md#catalogue-pull). 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: ```bash 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.db` on 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-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.