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>
4.6 KiB
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
{
"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.
{
"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.
SyncConfigcarries them and Settings displays the route, but there is no editor yet — a store is pointed at a broker in code or by overridingsyncConfigProvider. - 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.