Files
nearle_pos/docs/integration-guide.md
Suriya fe428931ec Upload shopper registrations, and charge GST to the lines that earned it
Two defects that share a shape: a figure landing on the wrong record.

Bill-level discounts were apportioned across every line by a single
factor, so "20% off Beverages" pulled tax out of the atta line as well.
The bill total was right either way, which is what made it easy to ship
— only the slab split on a filed return was wrong. Targeted campaigns
now reduce the lines they name, and bill-wide reductions still spread
pro rata, so the arithmetic is unchanged wherever it was already right.

Shoppers registered at a till only ever reached the back office as three
fields riding along on a bill. Somebody who signed up and bought nothing
existed on one terminal and nowhere else, and two tills registering the
same mobile each minted their own row. Customers are now an outbox of
their own on pos/{store}/{terminal}/customer, and the id is a UUIDv5
over the normalised mobile number — so a hundred terminals agree on who
a shopper is without talking to each other.

Registrations go up before bills, and a failure there cannot strand a
day's takings. No loyalty figures are sent: they belong to the bill
stream, which is idempotent and knows about every counter.

Two things found while building it. Numbers were keyed on raw digits, so
a cashier typing +91 forked a shopper as effectively as a random id
would. And the sale path wrote the customer with ConflictAlgorithm
.replace, which is a DELETE and an INSERT — every column absent from the
row reverts to its schema default, so the new sync flag would have been
cleared by the shopper's next purchase.

Schema v8. Existing customers are queued rather than assumed sent: the
terminal cannot tell an imported row from a locally registered one, and
only one of those mistakes loses somebody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:34:27 +05:30

308 lines
10 KiB
Markdown

# Connecting a terminal to your back office
Step-by-step wiring. The companion to [`sync-contract.md`](sync-contract.md),
which specifies *what* the back office must implement — this one covers *how* to
get a terminal talking to it, and how to prove each hop works before moving to
the next.
Nothing here needs a rebuild. A terminal is pointed at a back office from
Settings.
---
## The five hops
```
1. NATS running with the MQTT gateway on
2. Terminal connects and shows LIVE
3. Terminal publishes a bill
4. Your consumer commits it and acks
5. Terminal marks it synced and stops re-sending
```
Do them in order. A failure at hop 4 looks identical to a failure at hop 2 from
the terminal's side — it just keeps queueing — so proving each one saves a lot of
guessing.
---
## Hop 1 — NATS with MQTT enabled
MQTT needs JetStream turned on, because the gateway stores session state in it.
```conf
# nats.conf
jetstream {
store_dir: /var/lib/nats
max_file: 10Gi
}
mqtt {
port: 1883
# TLS in production. Bills carry customer names and mobile numbers.
# tls {
# cert_file: "/etc/nats/server.pem"
# key_file: "/etc/nats/server-key.pem"
# }
}
authorization {
users = [
{ user: "till", password: "…", permissions: {
publish: ["pos.*.*.order", "pos.*.*.customer", "pos.*.*.status"]
subscribe: ["pos.*.*.ack", "pos.*.*.command", "pos.*.catalogue"]
}}
]
}
```
Note the permissions are deliberately narrow. A terminal has no business
publishing to another terminal's ack topic.
Prove it:
```bash
nats sub 'pos.>' &
mosquitto_pub -h localhost -p 1883 -t pos/test/T0000/order -m 'hello'
# the nats sub should print it — that is the / → . mapping working
```
**The stream must be file-backed.** A memory stream loses a shop's bills on a
server restart, and the terminal has already been told they landed.
```bash
nats stream add POS_ORDERS \
--subjects 'pos.*.*.order' \
--storage file \
--retention limits \
--max-age 720h
```
---
## Hop 2 — Point the terminal at it
On the terminal: **Settings → Connectivity & sync → Configure**.
| Field | Value |
|---|---|
| Terminal name | What staff call this till, e.g. "Counter 2" |
| Store ID | Namespaces the shop on the broker. Must match across its tills |
| Transport | MQTT |
| Broker host / port | Your NATS host, `1883` plain or `8883` TLS |
| Username / password | From the `authorization` block above |
| Use TLS | On in production |
The dialog also shows a **Device ID** and a terminal code like `T4A9`. Neither is
editable. They are minted on first run and stay with the physical machine, which
is what keeps 100 terminals from colliding on topics, client ids and invoice
numbers. Note the code down — it is what a support call needs.
Credentials go to the OS keystore (Keychain / Credential Manager / Android
Keystore), not into the database alongside the bills.
Prove it: the header pill switches from `OFFLINE (SIM)` to `LIVE`, and
```bash
nats sub 'pos.*.*.status'
```
should immediately show a retained presence record for the terminal. If it
doesn't, the terminal never connected — check the broker log for an auth
rejection before looking anywhere else.
---
## Hop 3 — Ring a sale and watch it publish
```bash
nats sub 'pos.*.*.order'
```
Ring a bill on the terminal. Within a couple of seconds you should see the
envelope from [`sync-contract.md`](sync-contract.md#payloads) — a `batch_id`, the
store and terminal, and an `orders` array.
The header pill will show `1 QUEUED` and stay there, because nothing has
acknowledged it yet. That is correct behaviour, not a fault.
---
## Hop 4 — Consume, commit, acknowledge
This is the hop that matters. Two rules, both non-negotiable:
**Acknowledge from the consumer, after the database commit.** Not from an ingest
handler that has merely queued the work. That ack is the terminal's only
evidence, and it deletes its own copy seven days later on the strength of it.
**Be idempotent on `order.id`.** QoS 1 is at-least-once and a lost ack makes the
terminal re-send the whole batch. Every id is a UUID minted at the till, so this
costs you one unique index.
```sql
CREATE TABLE orders (
id UUID PRIMARY KEY, -- the terminal's order id
invoice_number TEXT NOT NULL,
store_id TEXT NOT NULL,
terminal_id TEXT NOT NULL,
cashier TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
subtotal NUMERIC(12,2) NOT NULL,
discount NUMERIC(12,2) NOT NULL,
tax NUMERIC(12,2) NOT NULL,
round_off NUMERIC(12,2) NOT NULL,
total NUMERIC(12,2) NOT NULL,
payload JSONB NOT NULL -- keep the raw envelope
);
-- Invoice numbers are unique per terminal, not globally. A till that was
-- replaced restarts its own series, so gaps are normal and do not mean
-- missing bills.
CREATE UNIQUE INDEX orders_invoice_per_terminal
ON orders (terminal_id, invoice_number);
```
Consumer shape:
```python
for msg in subscribe("pos.*.*.order"):
batch = json.loads(msg.data)
accepted, rejected = [], {}
with db.transaction(): # one transaction for the batch
for order in batch["orders"]:
try:
db.execute("""
INSERT INTO orders (id, invoice_number, …, payload)
VALUES (%(id)s, %(invoice_number)s, …, %(payload)s)
ON CONFLICT (id) DO NOTHING
""", order)
accepted.append(order["id"])
except BusinessRuleError as e:
rejected[order["id"]] = str(e)
# Published only after the commit above has succeeded.
publish(f"pos.{batch['store_id']}.{batch['terminal_id']}.ack", json.dumps({
"batch_id": batch["batch_id"],
"accepted": accepted,
"rejected": rejected,
}))
```
`ON CONFLICT DO NOTHING` still counts as accepted — a redelivery of a bill you
already hold is a success, not a rejection.
### Shopper registrations
A second uplink runs on `pos/{store}/{terminal}/customer`, acked on the same
topic by the same rules. Wire it the same way:
```sql
CREATE TABLE customers (
id UUID PRIMARY KEY, -- derived from the mobile number
mobile TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
email TEXT,
gender TEXT,
date_of_birth DATE,
registered_at TIMESTAMPTZ,
registered_by_terminal TEXT
);
```
Insert with `ON CONFLICT (id) DO NOTHING`**never** an upsert. A registration
is replayed freely and must not overwrite a profile corrected at head office.
The id is a UUIDv5 over the shopper's normalised ten-digit mobile, so two tills
registering the same person independently produce the same row. Don't reassign
it. And don't expect loyalty points in this payload: derive those from the bill
stream, which is idempotent and knows about every counter.
```bash
nats sub 'pos.*.*.customer'
```
Add a shopper on the terminal — no sale needed — and it should appear.
**Use `rejected` sparingly.** Naming an id there halts the terminal's drain: it
stops retrying and waits for a person to press Sync. That is right for "this bill
is malformed" and wrong for "my database is having a bad minute" — for the
latter, don't ack at all and let the terminal back off and retry.
Prove it by hand before wiring the real consumer:
```bash
# copy batch_id and the order id from the hop-3 output
nats pub 'pos.store-01.T4A9.ack' \
'{"batch_id":"<paste>","accepted":["<paste-order-id>"]}'
```
The pill should flip to `LIVE` and the bill disappear from the queue.
---
## Hop 5 — Catalogue down
The catalogue is a bulk pull over HTTP, not MQTT — a broker is the wrong shape
for tens of thousands of rows. Set the **Base URL** in the same Configure dialog
and implement:
```
GET {base}/catalogue?since={revision}&page={n}&store_id=…&terminal_id=…
Authorization: Bearer {apiKey}
```
Full field-by-field behaviour, including what happens when something is missing,
is in [`sync-contract.md`](sync-contract.md#catalogue-pull). The two things
easiest to get wrong:
- **`is_delta` is load-bearing.** A full snapshot withdraws every product it does
not mention. Answer `is_delta: true` for a change set, or the first morning
price change empties the shelf.
- **Send `stock` only when you mean it.** Any product in the payload gets its
count overwritten with your figure, which predates sales the terminal has rung
but not uploaded. The terminal replays those — but only for products the
payload carried.
To push a change mid-day rather than waiting for the next pull:
```bash
nats pub 'pos.store-01.catalogue' '{"revision":"rev-8822"}'
```
Every terminal in that store pulls immediately.
---
## Troubleshooting
| Symptom | Where to look |
|---|---|
| Pill stuck on `OFFLINE (SIM)` | Simulate offline is still on in Settings |
| Pill shows `LIVE`, no presence on `pos.*.*.status` | Terminal never connected — check broker auth logs |
| Bills publish, queue never empties | You are acking the wrong `batch_id`, or not acking at all |
| Queue empties then refills with the same bills | Ack arriving after `ackTimeout` (20s default) — the terminal gave up and re-sent |
| Two terminals fighting for the connection | They share a client id. Each device mints its own; check they have different terminal codes |
| `SYNC HALTED` | You named an id in `rejected`. The reason is on the pill tooltip and in Events |
| Duplicate rows server-side | No unique index on `order.id`. At-least-once delivery makes it mandatory |
| Shelf empties after a price change | You sent a delta with `is_delta: false`, or a stale `stock` |
The **Events** module on the terminal shows every sync attempt with its error,
and per-bill state — start there before the broker logs.
---
## Before a fleet rollout
- **Back up `nearle_pos.db` on any terminal already trading.** The schema goes to
v8 on first launch and the migration is one-way. It also queues every shopper
already on the terminal for upload, so expect one burst of registrations from
each existing store — collapse those onto the mobile number.
- **Build per-ABI.** `flutter build apk --split-per-abi` gives ~23MB per
architecture instead of a 69MB universal APK — worth it over shop wifi.
- **Change the seed PINs.** `4821` / `5093` / `6274` are in the source. Every
account is flagged to force a change at first sign-in, but a shop that
dismisses it is running a published credential.
- **Turn TLS on.** Bills carry customer names and mobile numbers.