Files
backend_fiesta/docs/POS_TERMINAL_INGEST.md
Suriya c0a7fbc1b1 Stop the till and Nearle Daily from sharing accounts
app_users is the only thing the two products have in common, and the code was
treating it as though it were the whole relationship. Both directions leaked.

Back-office roles were leaking into the till. PosRoleCanManageStaff returned
true for roleid 1 to 6, on the reasoning that somebody who already administers a
shop from a browser is not made less privileged by standing at the counter. That
sounds fine and is wrong: measured against live data it handed till-supervisor
powers to 68 accounts, 59 of them Nearle Daily Super admins, not one of whom is
the administrator of anybody's POS. Meanwhile the actual shop accounts carry
roleid 0 and were refused, so the mapping was backwards from intent in both
halves at once.

Till accounts were leaking into the application. GetStaffs is WHERE tenantid
with no role filter, so a Counter Cashier appeared in the tenant staff list
beside the delivery riders — a row every action on that page would fail against,
since a cashier has no app login, no rider shift and no back-office screen.

So: eligibility for a till is now granted explicitly by provisioning a
Supervisor or a Cashier, never inherited from a back-office role, and roles 7
and 8 are excluded from every Nearle Daily lookup. The exclusion lives in the
queries rather than in a check after them, because a check bolted on afterwards
has to be repeated at six call sites and is one edit away from being forgotten
at one of them — and that one would be the hole. A till account is not rejected
by the app login; it is not found.

Two things this surfaced that were not visible before.

A Supervisor could not open a till. PIN sign-in needs a session that already
exists, so once back-office roles were refused, an outlet whose only POS
accounts were PIN-only had no way in at all. Supervisors are now provisioned
with a username and password as well as a PIN; cashiers deliberately get neither,
because they sign on at a counter somebody has already opened and a second
password would be one more credential to leak for no capability gained.

UpdatePosUser silently dropped authname. It wrote the password, reported
success, and left the account unreachable by either lookup — the failure
surfaced at a counter as "not recognised" rather than on the screen that caused
it. Contactno had the same gap.

Verified against live rows rather than asserted, by scratch/posseparation: a
provisioned supervisor signs in and gets the supervisor shell; five real
back-office accounts including Super admins are refused; the supervisor is
invisible to applogin, tenant weblogin and the password-setup lookup; and no
till account appears in getallusers, while asking for role 7 by name still
returns them so the console can read its own people.

All five outlets that stock products now have a Supervisor and a Cashier.

Also moves the loose markdown into docs/, which was already staged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:52:23 +05:30

17 KiB
Raw Blame History

POS terminal ingest

How an in-store Nearle POS till reaches this backend. The terminal side of the contract is specified in the POS repository at docs/sync-contract.md; this covers what was built here and how to turn it on.

What a terminal expects, and why it matters

A till stores every bill in its own SQLite database the moment a sale completes, and keeps it for seven days after we acknowledge it. It marks a bill synced if — and only if — the bill's id appears in the accepted list of our reply.

That single rule drives every decision below:

  • Silence is not acceptance. No reply, an empty reply, a 200 with no body: all leave the bill on the till, and it is sent again. This is the correct behaviour when we are struggling, and it is why a failing ingest never acknowledges.
  • A duplicate is a success. Delivery is at-least-once. A lost ack makes a terminal re-send bills we already hold, and calling those failures would strand a day of takings. The ingest recognises them and accepts them without touching stock again.
  • A rejection is a decision. Naming an id in rejected stops the till retrying it and waits for a person. Right for "this bill is malformed", wrong for "the database is having a bad minute".

Two ways in, one code path

Both transports call services.PosService, so a bill arriving over MQTT and one arriving over HTTP cannot diverge.

Where a bill lands

Counter sales are written to pos_orders / pos_order_items, not to orders. A bill is a different document from an app order: it carries a cashier, a terminal, a rounding adjustment, promo campaigns, loyalty movement and a payment split across several tenders, none of which orders has anywhere to put. Forcing one into the other's shape loses whichever fields do not fit, silently.

Stock is not separate. A counter sale writes the same productstocks "out" rows an app order does, through the shared helpers in stockLedger.go — the same row locks, the same availability check, the same availability re-sync. Two stock ledgers would mean the catalogue pull sends a till figures that ignore the till's own trading, and it would oversell.

Existing revenue queries were extended to include pos_orders (GetRevenueSummary, GetSalesSummary), so dashboards do not understate a shop that runs a counter. Any new report has to remember to do the same — that is the standing cost of the split.

HTTP

Base path: /live/api/v1/pos

Written by a terminal — bare-ack responses, see below.

Method Path Purpose
POST /orders Completed bills
POST /customers Shoppers registered at a till
GET /catalogue Product pull. Query: store_id, since, page, page_size

Read by the web app — normal {code, message, status, details} envelope.

Method Path Purpose
GET /sales Bills for an outlet, newest first
GET /sales/detail One bill with its lines
GET /sales/summary Totals by tender, day and till
GET /health/terminal One till's live state
GET /health/location Every till at a shop

/sales and /sales/summary take: locationid (required), fromdate, todate (YYYY-MM-DD, matched on businessdate), terminalid, cashiername, paymentmode, pageno, pagesize.

/sales/detail takes locationid and reference — the terminal's order UUID, the invoice number, or the posorderid, whichever the caller happens to have.

locationid is the authorisation boundary. Every read is scoped to one outlet; omitting it is an error rather than a page through every shop's takings, and asking for a bill under the wrong outlet returns 404 even when the reference is valid.

Dates match businessdate — the day the sale was rung, not the day it reached us. A till that was offline overnight uploads yesterday's bills this morning and they belong to yesterday.

These answer with a bare ack, not the usual {code, message, status} envelope — the terminal reads accepted from the top level of the body:

{ "batch_id": "9f1c…", "accepted": ["order-uuid"], "rejected": {} }

Status codes carry the rest of the contract:

  • 200 — batch processed. Individual bills may still be refused; the ack says which.
  • 4xx — the request is wrong (unknown outlet, bad store id). The till halts and shows a person.
  • 5xx — outcome unknown. The till keeps everything and retries with backoff.

MQTT

The broker is Eclipse Mosquitto 2.1.2 at 66.116.225.226:1883, shared with the rider app (nearle/riders/#) and the doormile project (doormile/riders/#).

There is no NATS in this deployment. NATS servers exist for other projects on other hosts, but their ports are closed from here and their configs carry no mqtt {} block, so they expose no MQTT gateway. The NATS consumer that briefly lived in this package has been deleted rather than left to rot.

Env Purpose
MQTT_URL tcp://66.116.225.226:1883. Unset disables MQTT ingest — HTTP still works
MQTT_USER / MQTT_PASSWORD Broker credentials
MQTT_CLIENT_ID Defaults to nearle-pos-ingest. Must be unique per replica — a second connection with the same id evicts the first, and the two would fight in a loop
Topic Direction
nearle/pos/+/+/order till → us
nearle/pos/+/+/customer till → us
nearle/pos/+/+/health till → us, every 30s
nearle/pos/{loc}/{terminal}/ack us → till
nearle/pos/{loc}/catalogue us → every till at a shop

Subscribed with CleanSession(false) and a stable client id, so a brief restart resumes rather than missing what arrived meanwhile. Re-subscribes on every reconnect, because a broker that did not persist the session would otherwise come back subscribed to nothing.

Do not treat the broker as durable storage. Two measured facts make that unsafe:

  • max_queued_messages is at its default of 1000. If this backend is down long enough for a hundred tills to exceed that, Mosquitto silently drops the overflow.
  • Mosquitto's autosave_interval defaults to 30 minutes, so a hard kill can lose up to half an hour of persisted state.

Neither loses a bill, and that is the whole point of the acknowledgement design: a dropped message is simply never acked, so the terminal keeps its copy and sends it again. The broker is a transport, not a ledger.

The store and terminal are read from the topic, never from the body. A till that could name its own store in the payload could redirect another counter's acknowledgements.

Configuring a terminal

Settings → Connectivity & sync → Configure.

Field Value
Store ID The numeric locationid. Not a name — the tenant is resolved from it
Terminal name Whatever staff call the till
Transport HTTP or MQTT
Base URL (HTTP) https://your-host/live/api/v1/pos
Broker host / port (MQTT) 66.116.225.226, port 1883, TLS off (8883 is not configured)

store_id carrying the locationid is load-bearing: resolvePosStore looks the tenant up from it and refuses a location that is not registered. A terminal cannot name its tenant.

Mapping decisions

Worth knowing before the first bill lands.

  • Idempotency is a unique index on pos_orders.terminalorderid — the UUID minted at the till — plus a Postgres advisory lock held for the life of the transaction, so a redelivery arriving concurrently waits and then sees the committed row rather than racing past the check.
  • businessdate is the day the sale was rung, not the day it arrived. A till that was offline overnight uploads yesterday's bills this morning, and they belong to yesterday. Every daily figure keys on this.
  • Line amounts are scaled onto the bill total. The till sends each line at its pre-apportionment value while the header carries the total after bill-level discounts. Left alone the item rows would sum to the subtotal and every report that adds up lines would disagree with the one reading the header.
  • Payment mode is the largest tender on a split bill; the full split is kept verbatim in paymentsjson for drawer reconciliation.
  • Fractional quantities round up for stock. productstocks.quantity is an integer column, so 1.5 kg of onions cannot be recorded exactly. Rounding up never under-deducts, so recorded stock is never higher than the shelf. The app order path truncates instead (1.5 → 1), which under-deducts; that behaviour was left untouched rather than silently changed for live traffic. Making the column numeric is the real fix.
  • Customers match on contactno within the outlet's applocationid, so a shopper registered at a till and one who installed the app become one row. Registrations are insert-if-absent — never an update, so a profile corrected at head office is not reverted by a terminal replaying an old capture.
  • Catalogue answers a snapshot or a change set, decided by the since revision — see below.
  • Barcodes come from products.productsku — there is no barcode column. Scanning at the till matches on it, so SKUs must be the scannable code for barcode scanning to work.

Catalogue: snapshots and deltas

GET /catalogue?store_id=1135 with no since returns a full snapshot. The response carries a revision; the terminal stores it and sends it back next time as since=, and then gets only what changed.

A product is included in a change set when any of three things moved: the product row (name, tax, brand), its row at this outlet (price, availability), or its stock ledger. Stock counts because a shop's figure drifts from a till's on every sale rung at another counter, and a delta that ignored it would let that drift persist until someone forced a full pull.

The one rule that matters. A response marked is_delta: false is treated as a snapshot, and the terminal withdraws every product it does not mention. A filtered result labelled false therefore empties the shop's shelf. The filter and the flag are computed from a single value in Catalogue() — there is no path that filters without also setting the flag, and that is deliberate.

A revision that cannot be read falls back to a full snapshot. Malformed, empty, or issued to a different outlet — all yield a zero cutoff and a complete response. The other direction would leave a terminal permanently missing every change it had not already seen, with nothing to indicate it.

The revision only advances on the final page. A terminal that abandons a paginated pull half way gets back the revision it already had — or an empty one, meaning the next pull is a snapshot. Both are recoverable; a prematurely advanced revision is not.

A delta cannot withdraw a deleted product. A row removed from productlocations leaves no tombstone, so nothing tells the change set to retire it. Only a snapshot collects those, which is why a terminal should pull without a revision periodically — the morning import is the natural moment.

Terminal health

Every till publishes a heartbeat to nearle/pos/{loc}/{terminal}/health every 30 seconds. It is stored in Redis, never in Postgres.

Env Purpose
REDIS_HOST / REDIS_PORT Unset disables presence. Point at the same Redis the express backend uses
REDIS_USER / REDIS_PASSWORD / REDIS_DB Defaults default, empty, 0
pos:terminal:{terminalcode}          HASH, TTL 90s
pos:location:{locationid}:terminals  SET,  no TTL

The TTL is the whole design. A heartbeat is a fact with an expiry date: a till that loses power stops refreshing, the key expires, and it disappears from the board with nothing having to notice. In Postgres this would need ~288,000 writes a day across a hundred tills and a reaper job, because a row saying "online" cannot age out by itself.

90 seconds is three missed beats. Two would make an ordinary GPRS hiccup look like a dead till; five would take two and a half minutes to notice a real one.

The set has no TTL, mirroring city:{tenantid}:active_deliveries in the express backend: it is an index of what exists, not a claim that any of it is alive. Membership means "this till has been seen here"; liveness is whether the hash still exists.

Keys are namespaced pos:* and do not collide with express's delivery:*, city:* or rider_*. Worth keeping that way — a shared datastore only stays safe while each writer's keys are obviously its own.

A heartbeat is never acknowledged. Presence is fire-and-forget: a till that stopped selling because a dashboard was busy would be a self-inflicted outage.

Read it back:

Method Path
GET /live/api/v1/pos/health/terminal?terminal_id=T4A9
GET /live/api/v1/pos/health/location?location_id=12

A till whose key has expired comes back marked offline rather than being omitted — omitting it would make a dead terminal indistinguishable from one that was never installed, and the dead one is exactly what somebody is looking for.

What a heartbeat carries: identity and app version; queue depth (pending_bills, pending_registrations, oldest_pending_at) — the numbers that make a silent sync failure visible; today's trading (today_bills, today_amount, last_bill_at) — a till that is connected but has rung nothing in three hours is usually a jammed printer or an absent cashier; and device state.

Not built

  • Loyalty coming back down. The uplink deliberately carries no points or spend — those belong to the bill stream, which is idempotent and sees every counter. Nothing yet computes them centrally and sends them to the tills, so a shopper's balance at a till is that till's view.
  • Device authentication. A terminal is trusted with a locationid. Signed device tokens are the obvious next step before this is exposed publicly.
  • Battery and free storage in the heartbeat. The reporter has a hook for them, but this build collects neither — they need platform packages a desktop build has no use for. Fields that are not collected are omitted, not sent as zero: a board showing every till at 0% battery is worse than one showing nothing.

Broker accounts

Applied 2026-08-03 on 66.116.225.226. Two scoped accounts now exist alongside admin, with an ACL at /mosquitto/config/acl referenced from mosquitto.conf.

User May publish May subscribe
pos_terminal nearle/pos/+/+/{order,customer,status,health} nearle/pos/+/+/{ack,command}, nearle/pos/+/catalogue
pos_ingest nearle/pos/+/+/{ack,command}, nearle/pos/+/catalogue nearle/pos/+/+/{order,customer,health,status}
admin everything — deliberately unchanged everything

A till therefore cannot publish to nearle/riders/# or doormile/#, and cannot write its own ack topic — only the ingest may do that. Verified by publishing as pos_terminal to all four and watching which arrived: the order did, the other three did not.

admin was left unrestricted on purpose. Its credentials are compiled into the rider app, so narrowing it here would cut off the live rider fleet without warning. The right next step is:

user admin
topic readwrite nearle/riders/#
topic readwrite doormile/#

but only once someone has confirmed nothing else authenticates as admin. Until then the ACL changes nothing for it — which is why applying it was safe.

Rollback, if ever needed:

cp /root/Mqtt/backup-<timestamp>/{mosquitto.conf,passwd} /root/Mqtt/config/
docker restart mqtt_broker

Still outstanding on the broker:

  • No TLS. Port 8883 is not configured. Bills carry customer names and mobile numbers, and they travel in the clear. Traefik on the same host already terminates 443, so certificates exist to borrow from.
  • passwd is world-readable. Mosquitto warns about it and future versions will refuse to load it. Tightening it means chown 1883:1883 as well as chmod, because the broker runs as uid 1883 and a root-owned 0600 file would stop it starting.
  • Credentials in source. admin is in the rider APK, Redis is hardcoded in the express backend, and Postgres was in this repository's git history until 2026-08-03. The POS accounts above are the only ones not in any source tree — keep it that way.

Capacity

Current load, measured: ~0.9 msg/s inbound, 5 connected clients, 112 retained messages totalling 8 KB.

A hundred tills add roughly 3.3 msg/s steady (a 1 KB heartbeat each per 30s) plus bursts of up to ~50 KB when a sale batch goes up. That is 34× current traffic and well within what Mosquitto handles on any VPS. The broker will not be the bottleneck; Postgres write throughput on bill ingest is the thing to watch instead.