Publish under nearle/pos, add a health heartbeat, send the GST slab split

Three changes, all driven by what the back office turned out to need.

The broker is shared with the rider fleet on nearle/riders/…, so topics
move under nearle/pos/{locationid}/{terminal}/… — one ACL rule per
system, and it is obvious from a topic which one owns it. Store ID now
carries the back office's numeric location id; the tenant is resolved
from it server-side and never taken from the wire.

A till publishes a heartbeat every 30 seconds on its own topic. The Last
Will already answers "is it dead", which is not enough to run a hundred
shops on: the failure that costs money is a terminal that is connected,
selling, and quietly holding two hundred bills it has never uploaded. So
the beat carries queue depth, the age of the oldest thing waiting,
today's trading, and printer reachability. Not retained — the back
office holds it under a TTL, and a retained beat would leave an
unplugged till looking alive until something overwrote it.

Bills now carry tax_breakdown, the GST slab split the cart already
computes. A tax return is filed per slab, and recomputing the split
server-side would mean redoing the discount apportionment and getting
exactly the same answer — or else the filed figure stops matching the
paper the shopper was handed.

Docs rewritten against the real deployment: Eclipse Mosquitto 2.1.2, no
NATS anywhere reachable, no TLS, and a broker whose queue and autosave
defaults mean it must not be treated as durable storage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-03 17:47:24 +05:30
parent 09edce5dc6
commit 33b4337933
9 changed files with 435 additions and 185 deletions

View File

@@ -62,15 +62,21 @@ connection or return 5xx instead, and the terminal will back off and retry.
| Topic | Direction | QoS | Retained |
|---|---|---|---|
| `pos/{store}/{terminal}/order` | till → cloud | 1 | no |
| `pos/{store}/{terminal}/customer` | 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** |
| `nearle/pos/{loc}/{terminal}/order` | till → cloud | 1 | no |
| `nearle/pos/{loc}/{terminal}/customer` | till → cloud | 1 | no |
| `nearle/pos/{loc}/{terminal}/health` | till → cloud | 1 | no |
| `nearle/pos/{loc}/{terminal}/ack` | cloud → till | 1 | no |
| `nearle/pos/{loc}/{terminal}/status` | till → cloud | 1 | **yes** |
| `nearle/pos/{loc}/{terminal}/command` | cloud → till | 1 | no |
| `nearle/pos/{loc}/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
Namespaced under `nearle/` alongside the rider fleet's `nearle/riders/…`, so one
broker ACL rule covers each system.
`{loc}` is the back office's numeric location id, entered once in Settings; the
tenant is resolved from it server-side and never taken from the wire.
`{terminal}` comes 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.
@@ -79,28 +85,28 @@ second connection with the same client id kicks the first off.
dark" board possible, and it is the only way to tell *closed for the night*
from *unplugged*.
### Running this on NATS
### Running this on Mosquitto
The MQTT gateway maps `/` to `.`, so the topics above arrive as subjects and a
JetStream consumer binds to them directly:
The deployed broker is Eclipse Mosquitto 2.1.2. A consumer binds to the topics
above directly, using `+` as the single-level wildcard:
| Purpose | Subject |
| Purpose | Filter |
|---|---|
| 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` |
| Every till's bills | `nearle/pos/+/+/order` |
| Every till's heartbeat | `nearle/pos/+/+/health` |
| One shop's bills | `nearle/pos/12/+/order` |
| Ack back to one till | `nearle/pos/12/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:
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.
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.
- **Do not treat the broker as durable storage.** Mosquitto's default
`max_queued_messages` is 1000 and its `autosave_interval` is 30 minutes, so a
long outage or a hard kill can drop queued messages. Nothing is lost, because
an undelivered batch is simply never acked and the till sends it again — but
only as long as nobody acknowledges on the broker's behalf.
### Fleet presence
@@ -127,7 +133,7 @@ so.
## Payloads
**Uplink**`pos/{store}/{terminal}/order`
**Uplink**`nearle/pos/{loc}/{terminal}/order`
```json
{
@@ -140,7 +146,7 @@ so.
}
```
**Ack**`pos/{store}/{terminal}/ack`. Must echo `batch_id`; anything else is
**Ack**`nearle/pos/{loc}/{terminal}/ack`. Must echo `batch_id`; anything else is
ignored as belonging to a batch the terminal is no longer waiting on.
```json
@@ -160,7 +166,7 @@ the same bills.
### Shopper registrations
**Uplink**`pos/{store}/{terminal}/customer`, or `POST {base}/customers`.
**Uplink**`nearle/pos/{loc}/{terminal}/customer`, or `POST {base}/customers`.
Acked on the same topic and by the same rules: only ids you name are marked
sent.
@@ -269,7 +275,7 @@ Dates are ISO 8601 or epoch milliseconds; both are accepted.
### Pushing a change mid-day
Publish to `pos/{store}/catalogue` (retained) and every terminal in the shop
Publish to `nearle/pos/{loc}/catalogue` (retained) and every terminal in the shop
pulls immediately instead of waiting for tomorrow morning. The message body is
only a nudge — the catalogue itself still comes over HTTP, because a broker is
the wrong shape for tens of thousands of rows.