Turns the orders table into a queue that empties itself. Bills were only uploaded when a cashier pressed Sync at end of day; a till that was never pressed held a day's takings indefinitely. Drain engine (lib/data/sync/sync_engine.dart) - Triggers on sale committed, network regained, 5-minute poll, head-office request, and the manual button. - Single flight: a busy till firing a trigger per sale would otherwise have several passes reading the same pending rows and send every bill twice. A trigger arriving mid-drain is queued and replayed, so nothing is dropped. - Exponential backoff with +/-20% jitter to a 5-minute ceiling. The jitter matters: a store's terminals all fail at the same instant when the line drops, and would retry in lockstep without it. - Halts rather than loops on a failure retrying cannot fix (bad credential, refused batch). Pressing Sync clears the halt. Transports (lib/data/remote/) - OrderTransport interface; MQTT, HTTP and simulated implementations. The repository does not know which is in use. - MQTT: QoS 1 uplink, application-level ACK correlated by batch_id on a return topic, retained Last Will for terminal-offline detection, downlink for catalogue pushes and remote sync requests. - A broker PUBACK is never treated as acceptance. It means the broker holds the bytes, not that the ledger took the sale. Only ids the back office names are marked synced; silence leaves a bill pending. - HTTP carries a stable idempotency key across retries of the same bills. Retention - Accepted bills are kept 7 days instead of deleted, so a batch the back office later loses can be re-sent in full. Purged after that; archived totals stay forever. - forBusinessDate now reads pending rows only. A retained bill exists in both the orders table and day_archive, and summing both would overstate the day. Fixes found while building this - SyncEngine._refreshPending wrote state.copyWith(pending: await ...). Dart evaluates the receiver before the awaited argument, so a connectivity drop during the wait was silently overwritten by the stale snapshot. Caught by the first run of the new engine tests. - PrinterSettingsController wrote state after four awaits with no mounted check, throwing "used after dispose" when Settings was left mid-load. This was pre-existing and reached the cashier as a red screen. Also - Header pill now reports real sync state: LIVE / n QUEUED / SYNCING / SYNC HALTED, with an explanation of where the bills are. - Settings shows the route, last upload, next retry and retention window. - docs/sync-contract.md states what the back office must implement, including the idempotency requirement that at-least-once delivery makes mandatory. Tests: 90 -> 129 passing. New coverage for backoff shape and jitter band, single flight, halting, ACK correlation and partial acceptance, at-least-once duplicate handling, retention and purge, and no double-counting after a sync. Suite run six times clean. Not addressed: bills already synced by an older build went up overstated and still need server-side reconciliation. Broker credentials have no Settings editor yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
4.6 KiB
Markdown
126 lines
4.6 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.
|
|
|
|
**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** |
|
|
|
|
`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*.
|
|
|
|
## Payloads
|
|
|
|
**Uplink** — `pos/{store}/{terminal}/order`
|
|
|
|
```json
|
|
{
|
|
"schema": 1,
|
|
"batch_id": "9f1c…",
|
|
"store_id": "store-01",
|
|
"terminal_id": "TERM-01",
|
|
"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`.
|