diff --git a/README.md b/README.md index 0bafbf9..c43b883 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,26 @@ Brand colour `#662582` · Inter typeface · 16px corner radius · touch-first ta ```bash flutter pub get 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 ``` +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. --- diff --git a/docs/integration-guide.md b/docs/integration-guide.md new file mode 100644 index 0000000..7543e2b --- /dev/null +++ b/docs/integration-guide.md @@ -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":"","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.