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

10 KiB

Connecting a terminal to your back office

Step-by-step wiring. The companion to 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.

# 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:

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.

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

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

nats sub 'pos.*.*.order'

Ring a bill on the terminal. Within a couple of seconds you should see the envelope from sync-contract.md — 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.

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:

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:

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 NOTHINGnever 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.

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:

# 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. 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:

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.