Drain bills to the back office automatically, over MQTT or HTTP
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>
This commit is contained in:
125
docs/sync-contract.md
Normal file
125
docs/sync-contract.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user