Add an integration guide for wiring a terminal to a back office
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>
This commit is contained in:
18
README.md
18
README.md
@@ -11,10 +11,26 @@ Brand colour `#662582` · Inter typeface · 16px corner radius · touch-first ta
|
|||||||
```bash
|
```bash
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter run -d windows # or macos, linux, or a connected tablet
|
flutter run -d windows # or macos, linux, or a connected tablet
|
||||||
flutter test # 60+ unit and widget tests
|
flutter test # 234 unit and widget tests
|
||||||
flutter analyze
|
flutter analyze
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Out of the box the terminal runs against a local stub: products are seeded,
|
||||||
|
bills queue and drain, and nothing leaves the device. Connecting it to a real
|
||||||
|
back office is a Settings change, not a rebuild — see below.
|
||||||
|
|
||||||
|
### Connecting to a back office
|
||||||
|
|
||||||
|
| Document | For |
|
||||||
|
|---|---|
|
||||||
|
| [Integration guide](docs/integration-guide.md) | Wiring a terminal to NATS/MQTT and an HTTP catalogue, hop by hop, with commands to prove each one |
|
||||||
|
| [Sync contract](docs/sync-contract.md) | What the back office must implement: topics, payloads, acknowledgement rules, field handling |
|
||||||
|
|
||||||
|
The short version: bills are written to SQLite first and uploaded in the
|
||||||
|
background, so the till never waits on the network. A bill is only marked
|
||||||
|
synced when the *back office* names its id — a broker acknowledging receipt is
|
||||||
|
not the ledger accepting the sale.
|
||||||
|
|
||||||
Requires Flutter 3.27 / Dart 3.6 or newer. See [Version compatibility](#version-compatibility) if `flutter analyze` complains about theme types.
|
Requires Flutter 3.27 / Dart 3.6 or newer. See [Version compatibility](#version-compatibility) if `flutter analyze` complains about theme types.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
273
docs/integration-guide.md
Normal file
273
docs/integration-guide.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# 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":"<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`](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.
|
||||||
Reference in New Issue
Block a user