Answers "which of my 100 tills are alive and healthy", and fixes three things that were fine on one device and broken on a hundred. Terminal identity (lib/data/local/terminal_identity.dart) - Every device mints a UUID on first run, stored in its own database, plus a short code (T4A9) derived from it. Renaming keeps the device id, so history keeps pointing at the same physical till. - Replaces the literal 'TERM-01', which was hardcoded in five places. The whole fleet reported as one terminal: shift reports merged, MQTT topics collided, and a second connection with the same client id evicts the first from the broker — so two tills would have knocked each other offline in a loop. Invoice numbers now carry the terminal code - INV-2608-T4A9-00042. The sequence counter lives in each till's own database and starts at 1, so without this every terminal in the fleet minted INV-2608-00001 for its first sale of the month. The order UUID kept the data distinct; the number a customer quotes on a receipt was not. SQLite pragmas - WAL, so the product grid refreshing does not block the sale being written, and the file is never left mid-rewrite by a power cut. - busy_timeout 5s, so a contended lock waits instead of throwing "database is locked" — which at checkout is a failed sale with a customer standing there. - synchronous NORMAL, the right trade under WAL for a till. Fleet presence (lib/data/sync/presence_reporter.dart) - Retained status record on connect and once a minute: device id, code, name, app version, pending bill count, last upload, catalogue revision, sync halt state. Retained so a dashboard connecting at noon gets all 100 terminals immediately rather than a blank board. - The Last Will already said "reachable". A till can be connected and still be holding 200 unsent bills or running last month's prices; only pending_bills and catalogue_revision say so. NATS - The MQTT gateway maps / to . so the existing transport works unchanged. SyncConfig.asNatsSubject() exposes the translation, and the contract doc gives the JetStream subjects (pos.*.*.order, pos.*.*.status) plus the two server-side requirements: a file-backed stream, and the ack published by the consumer after commit rather than by the ingest handler. Tests: 129 -> 140. New coverage for identity minting and stability, per-device invoice uniqueness, topic and client-id separation, NATS subject mapping, and the two pragmas. Suite run three times clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
7.0 KiB
Markdown
182 lines
7.0 KiB
Markdown
# Terminal ↔ back office sync contract
|
|
|
|
What the till guarantees, and what the back office must do to hold up its end.
|
|
Everything here is enforced by tests in `test/unit/retention_test.dart`,
|
|
`test/unit/transport_test.dart` and `test/unit/sync_engine_test.dart`.
|
|
|
|
## The shape
|
|
|
|
```
|
|
Customer pays
|
|
│
|
|
▼
|
|
One SQLite transaction: bill + stock + loyalty ← never blocked on network
|
|
│
|
|
▼
|
|
orders row lands at sync_status = 0 ← this table IS the outbox
|
|
│
|
|
▼
|
|
SyncEngine drains on: sale committed · network back · 5-min poll · head office
|
|
│ asked · cashier pressed Sync
|
|
▼
|
|
Transport publishes a batch
|
|
│
|
|
▼
|
|
Back office commits and names the ids it took
|
|
│
|
|
▼
|
|
Those rows → sync_status = 1, folded into day_archive, kept 7 days, then purged
|
|
```
|
|
|
|
Anything the back office does not name stays at 0 and goes again.
|
|
|
|
## Non-negotiables
|
|
|
|
**1. Only an application acknowledgement counts.**
|
|
A broker PUBACK means "I hold these bytes". It is not evidence the ledger
|
|
accepted anything, and the terminal never treats it as such. The back office
|
|
must answer on the ack topic naming the order ids it committed.
|
|
|
|
**2. Silence is not acceptance.**
|
|
A `200 OK` with an empty body, or an ack with no `accepted` array, marks *zero*
|
|
bills synced. The terminal will send them again rather than guess.
|
|
|
|
**3. Delivery is at-least-once, so the back office must be idempotent.**
|
|
QoS 1 re-delivers, and a lost ack makes the terminal re-send the whole batch.
|
|
Every `order.id` is a UUID minted at the till. Put a unique index on it and
|
|
upsert. Without this you will double-count a day's takings the first time a
|
|
shop's line wobbles.
|
|
|
|
Invoice numbers are `INV-2608-T4A9-00042` — the terminal code is in there
|
|
because each till's sequence counter lives in its own database and starts at 1.
|
|
Unique per terminal, not globally sequential. Do not assume gaps mean missing
|
|
bills; a till that was replaced restarts its own series.
|
|
|
|
**4. A refusal is final, a failure is not.**
|
|
Naming an id in `rejected` halts the terminal's drain — it will not retry the
|
|
same bytes, and a person has to press Sync. Use it for "this bill is wrong"
|
|
(unknown product, duplicate invoice). For "I am having a bad minute", drop the
|
|
connection or return 5xx instead, and the terminal will back off and retry.
|
|
|
|
## MQTT topics
|
|
|
|
| Topic | Direction | QoS | Retained |
|
|
|---|---|---|---|
|
|
| `pos/{store}/{terminal}/order` | till → cloud | 1 | no |
|
|
| `pos/{store}/{terminal}/ack` | cloud → till | 1 | no |
|
|
| `pos/{store}/{terminal}/status` | till → cloud | 1 | **yes** |
|
|
| `pos/{store}/{terminal}/command` | cloud → till | 1 | no |
|
|
| `pos/{store}/catalogue` | cloud → all tills | 1 | **yes** |
|
|
|
|
`{store}` and `{terminal}` come from the device's own identity, minted on first
|
|
run and stored in its database. They are never literals — 100 tills sharing one
|
|
id would collide on every topic and evict each other from the broker, since a
|
|
second connection with the same client id kicks the first off.
|
|
|
|
`status` is also the Last Will. If a till loses power the broker publishes
|
|
`{"state":"offline"}` on its behalf — that is what makes a "which tills are
|
|
dark" board possible, and it is the only way to tell *closed for the night*
|
|
from *unplugged*.
|
|
|
|
### Running this on NATS
|
|
|
|
The MQTT gateway maps `/` to `.`, so the topics above arrive as subjects and a
|
|
JetStream consumer binds to them directly:
|
|
|
|
| Purpose | Subject |
|
|
|---|---|
|
|
| Every till's bills | `pos.*.*.order` |
|
|
| Every till's presence | `pos.*.*.status` |
|
|
| One store's bills | `pos.store-01.*.order` |
|
|
| Ack back to one till | `pos.store-01.T4A9.ack` |
|
|
|
|
`SyncConfig.asNatsSubject()` does the translation, so a consumer's subject can
|
|
be read off the terminal rather than guessed.
|
|
|
|
Two things to get right on the NATS side:
|
|
|
|
- **The stream must be durable and file-backed.** A memory stream loses a shop's
|
|
bills on a server restart, and the till has already been told they landed.
|
|
- **Publish the ack from the consumer, after the database commit** — not from an
|
|
ingest handler that has merely queued the work. The ack is the terminal's
|
|
only evidence, and it deletes its copy seven days later on the strength of it.
|
|
|
|
### Fleet presence
|
|
|
|
Every terminal publishes a retained record on its status topic on connect and
|
|
once a minute. Retained matters: a dashboard connecting at noon gets all 100
|
|
terminals' last state immediately instead of a blank board.
|
|
|
|
```json
|
|
{
|
|
"schema": 1, "state": "online",
|
|
"device_id": "…", "terminal_code": "T4A9", "terminal_name": "Counter 2",
|
|
"store_id": "store-01", "app_version": "1.1.0",
|
|
"reported_at": "2026-08-01T14:22:05Z",
|
|
"pending_bills": 3, "last_upload_at": "…", "catalogue_revision": "rev-8821",
|
|
"sync_halted": false, "sync_error": null, "consecutive_failures": 0,
|
|
"transport": "mqtt"
|
|
}
|
|
```
|
|
|
|
The Last Will answers *is it reachable*. These fields answer *is it healthy* —
|
|
a till can be connected and still be holding 200 unsent bills or running last
|
|
month's price list, and only `pending_bills` and `catalogue_revision` will say
|
|
so.
|
|
|
|
## Payloads
|
|
|
|
**Uplink** — `pos/{store}/{terminal}/order`
|
|
|
|
```json
|
|
{
|
|
"schema": 1,
|
|
"batch_id": "9f1c…",
|
|
"store_id": "store-01",
|
|
"terminal_id": "T4A9",
|
|
"sent_at": "2026-08-01T14:22:05.123Z",
|
|
"orders": [ { "id": "…", "invoice_number": "…", "items": [ … ] } ]
|
|
}
|
|
```
|
|
|
|
**Ack** — `pos/{store}/{terminal}/ack`. Must echo `batch_id`; anything else is
|
|
ignored as belonging to a batch the terminal is no longer waiting on.
|
|
|
|
```json
|
|
{
|
|
"batch_id": "9f1c…",
|
|
"accepted": ["order-uuid-a", "order-uuid-b"],
|
|
"rejected": { "order-uuid-c": "duplicate invoice number" }
|
|
}
|
|
```
|
|
|
|
No ack within `SyncConfig.ackTimeout` (20s default) → the outcome is unknown,
|
|
nothing is marked synced, and the batch goes again.
|
|
|
|
**HTTP equivalent** — `POST {base}/orders`, same body, ack shape as the 200
|
|
response. Carries an `idempotency-key` header that is stable across retries of
|
|
the same bills.
|
|
|
|
## Retention on the terminal
|
|
|
|
Accepted bills stay for 7 days (`OrderDao.retentionWindow`) so a batch the
|
|
back office later loses can be re-sent in full. After that only the archived
|
|
day totals survive, and a lost bill's line items are gone for good.
|
|
|
|
While a bill is retained it exists in two places — its own row and
|
|
`day_archive`. `forBusinessDate` therefore reads **pending rows only**; without
|
|
that filter every synced bill would be counted twice and the shift report would
|
|
overstate the day.
|
|
|
|
## What is deliberately not built
|
|
|
|
- **Downlink beyond catalogue-changed and sync-requested.** The plumbing routes
|
|
unknown commands to the events log rather than dropping them, so adding one
|
|
is a server change plus a case arm.
|
|
- **Broker credentials in Settings.** `SyncConfig` carries them and Settings
|
|
displays the route, but there is no editor yet — a store is pointed at a
|
|
broker in code or by overriding `syncConfigProvider`.
|
|
- **Historical correction.** Bills already synced by an older build went up
|
|
with an overstated total. Nothing here fixes that; it needs a server-side
|
|
reconciliation against `bill_discount`.
|