An API reference and rationale for whoever picks this up next. The endpoints are the easy half; what is not obvious from reading the code is why an ack is only published after the commit, why a duplicate counts as a success, and why is_delta false on a filtered catalogue empties a shop's shelf. Those are written down here because each one costs a shop money when someone changes it without knowing. Covers the GET endpoints in full — request parameters, real response shapes, and the cases that surprise people: values coming back as strings from a Redis hash, a quiet till answering 200 rather than 404, and a bill at another outlet returning 404 even though the reference is valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
POS integration — handover
Everything a developer needs to work on, extend or debug the in-store POS
integration. Companion to POS_TERMINAL_INGEST.md,
which covers deployment and broker setup.
Base path for everything below: /live/api/v1/pos
1. What this is
Retail tills run a Flutter POS app. Each one holds its own SQLite database and keeps working with no network at all. When a connection is available it publishes completed bills to an MQTT broker; a consumer in this backend commits them to Postgres and acknowledges.
Cashier completes sale
│
▼
Till's SQLite ───────────────────── one transaction, before any network
sync_status = 0 survives crash, power cut, dead wifi
│
▼
MQTT broker ──────────────────────── transit only, holds nothing you can rely on
nearle/pos/{loc}/{terminal}/order
│
▼
fiesta consumer (messaging/posmqtt.go)
│
▼
POSTGRES ─────────────────────────── the permanent record
pos_orders, pos_order_items
productstocks (stock deducted here)
│
▼
ack → nearle/pos/{loc}/{terminal}/ack
│
▼
Till marks it synced, keeps its copy 7 more days, then purges
Health takes a separate path: every till publishes a heartbeat every 30 seconds, which lands in Redis under a 90-second TTL. Never in Postgres.
2. The four rules everything rests on
Break any of these and shops lose money. They are not stylistic.
1. Only an application acknowledgement counts. A broker PUBACK means "I hold these bytes". It is not evidence the database accepted anything. The ack is published after the transaction commits, never from a handler that has merely queued the work.
2. Silence is not acceptance. No ack, an empty ack, 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.
3. A duplicate is a success.
Delivery is at-least-once. A lost ack makes a terminal re-send bills we already
hold. Reporting those as failures would strand a day of takings. Deduplication
is a unique index on pos_orders.terminalorderid — the UUID minted at the till
— plus a Postgres advisory lock held for the transaction.
4. Store and terminal come from the topic, never the body. A till that could name its own store in a payload could redirect another counter's acknowledgements.
3. Endpoints written by a terminal
These answer with a bare body, not the usual {code, message, status}
envelope — the till reads accepted from the top level and marks a bill synced
only if its id is there. Wrapping it would leave every terminal queueing for
ever.
POST /orders
{
"schema": 1,
"batch_id": "9f1c…",
"store_id": "1135",
"terminal_id": "T4A9",
"orders": [{
"id": "99999999-8888-4777-8666-555555555555",
"invoice_number": "INV-2608-T4A9-00002",
"created_at": "2026-08-03T17:29:00Z",
"cashier": "Divya",
"customer": {"id": "…", "mobile": "9840099999", "name": "Ravi"},
"subtotal": 60.0, "discount": 0.0, "tax": 4.44,
"tax_breakdown": {"0.08": 4.44},
"round_off": 0.0, "total": 60.0,
"points_earned": 0, "points_redeemed": 0,
"payments": [{"method": "upi", "amount": 60.0, "reference": "TXN123"}],
"items": [{
"product_id": "6988", "barcode": "6988", "name": "Mysore Banana",
"quantity": 1, "unit_price": 60.0, "discount": 0.0,
"gst_rate": 0.08, "tax": 4.44, "line_total": 60.0
}]
}]
}
Response:
{ "batch_id": "9f1c…", "accepted": ["99999999-…"], "rejected": {} }
Status codes carry the other half of the contract:
| Code | Meaning | Terminal does |
|---|---|---|
200 |
batch processed; ack says which bills landed | marks the named ids synced |
4xx |
the request is wrong — unknown outlet, bad store id | halts and shows a person |
5xx |
outcome unknown | keeps everything, retries with backoff |
POST /customers
Same envelope with a customers array. Insert-if-absent on id — never an
update, so a profile corrected at head office is not reverted by a terminal
replaying an old capture.
The id is a UUIDv5 over the normalised ten-digit mobile, so two tills registering the same shopper independently produce the same row. Do not reassign it.
No loyalty figures travel upward — points and spend are derived from the bill stream, which is idempotent and sees every counter.
4. GET endpoints — for the web app
These use the normal {code, message, status, details} envelope.
GET /sales — bills for an outlet
curl "$BASE/sales?locationid=1135&fromdate=2026-08-01&todate=2026-08-03"
| Parameter | |
|---|---|
locationid |
required — the authorisation boundary |
fromdate, todate |
YYYY-MM-DD, matched on businessdate |
terminalid |
e.g. T4A9 |
cashiername |
exact match |
paymentmode |
cash, card, upi, wallet |
pageno |
0-based, default 0 |
pagesize |
default 50, max 500 |
{
"code": 200, "status": true,
"details": {
"total": 137, "pageno": 0, "pagesize": 50,
"bills": [{
"posorderid": 7,
"terminalorderid": "99999999-8888-4777-8666-555555555555",
"invoicenumber": "INV-2608-T4A9-00002",
"tenantid": 1087, "locationid": 1135,
"terminalid": "T4A9", "cashiername": "Divya",
"customerid": 6847, "customermobile": "9840099999", "customername": "Ravi",
"billedat": "2026-08-03T17:29:00Z",
"businessdate": "2026-08-03",
"subtotal": 60, "discount": 0, "taxamount": 4.44,
"roundoff": 0, "total": 60,
"pointsearned": 0, "pointsredeemed": 0,
"itemcount": 1, "paymentmode": "upi",
"paymentsjson": "[{\"method\":\"upi\",\"amount\":60,\"reference\":\"TXN123\"}]",
"promosjson": "[]",
"taxbreakdownjson": "{\"0.08\":4.44}",
"batchid": "batch-mqtt-0001",
"receivedat": "2026-08-03T17:29:11Z"
}]
}
}
Line items are not included — a page of 50 bills would drag hundreds of rows
behind it and a list screen shows none of them. Use /sales/detail.
Ordered by billedat descending, not by id: a backlog uploaded after an outage
arrives out of order, and sorting by arrival would interleave yesterday's bills
through today's.
GET /sales/detail — one bill with its lines
curl "$BASE/sales/detail?locationid=1135&reference=INV-2608-T4A9-00002"
reference accepts any of three: the terminal's order UUID, the invoice
number, or the posorderid. A support call starts from whichever the caller
happens to be looking at.
{
"code": 200, "status": true,
"details": {
"posorderid": 7,
"invoicenumber": "INV-2608-T4A9-00002",
"…": "all the fields above, plus:",
"items": [{
"posorderitemid": 12, "posorderid": 7,
"productid": 6988, "productname": "Mysore Banana",
"barcode": "6988", "unitname": "kg",
"quantity": 1, "unitprice": 60,
"discountamount": 0, "gstrate": 0.08,
"taxamount": 4.44, "linetotal": 60
}]
}
}
Returns 404 if the reference does not belong to that locationid — even
when the reference is a real bill at another outlet.
GET /sales/summary — totals
curl "$BASE/sales/summary?locationid=1135&fromdate=2026-08-01&todate=2026-08-03"
Takes the same filters as /sales.
{
"code": 200, "status": true,
"details": {
"locationid": 1135,
"fromdate": "2026-08-01", "todate": "2026-08-03",
"billcount": 137, "itemcount": 402,
"grosssales": 18450.50, "taxcollected": 1204.30,
"discountgiven": 320.00, "roundoff": -1.50,
"averagebill": 134.68,
"bypaymentmode": [
{"paymentmode": "cash", "billcount": 80, "amount": 9200.00},
{"paymentmode": "upi", "billcount": 57, "amount": 9250.50}
],
"byday": [
{"businessdate": "2026-08-01", "billcount": 44, "amount": 5900.00}
],
"byterminal": [
{"terminalid": "T4A9", "billcount": 137, "amount": 18450.50}
]
}
}
Three breakdowns because they answer three different questions: by tender for reconciling a drawer, by day for a chart, by till for an outlet running several counters.
GET /health/terminal — one till
curl "$BASE/health/terminal?terminal_id=T4A9"
{
"code": 200, "status": true,
"details": {
"terminal_id": "T4A9", "location_id": "1135",
"store_name": "Ragul stores Selvapuram",
"app_version": "1.1.0", "status": "online",
"pending_bills": "0", "pending_registrations": "0",
"oldest_pending_at": "",
"today_bills": "2", "today_amount": "170",
"last_bill_at": "",
"printer_reachable": "0",
"reported_at": "2026-08-03T12:04:21Z",
"received_at": "2026-08-03T12:04:21Z"
}
}
Values are strings — it is a Redis hash. A till that has not reported inside
its TTL returns 200 with status: "offline", not a 404: it exists, it is
simply quiet.
Fields the till does not collect are absent, not zero. A board showing every terminal at 0% battery is worse than one showing nothing.
pending_bills is the number worth watching. A shop quietly accumulating
unsynced takings looks completely normal from the floor.
GET /health/location — the "which counters are dark" board
curl "$BASE/health/location?location_id=1135"
{
"code": 200, "status": true,
"details": {
"location_id": "1135", "total": 3, "online": 2,
"terminals": [
{"terminal_id": "T4A9", "status": "online", "today_bills": "37", "…": "…"},
{"terminal_id": "T7B2", "status": "offline", "reason": "no heartbeat within 90s"}
]
}
}
A till whose key expired comes back marked offline rather than 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.
GET /catalogue — the till's product pull
Bare body, no envelope. Used by terminals, not the web app.
curl "$BASE/catalogue?store_id=1135&page_size=500"
curl "$BASE/catalogue?store_id=1135&since=loc1135-20260803T135407Z"
No since → full snapshot. With a valid since → change set.
{
"revision": "loc1135-20260803T135407Z",
"is_delta": false,
"has_more": false,
"products": [{
"id": "6988", "name": "Mysore Banana",
"barcode": "6988", "sku": "",
"category": "grocery", "price": 60, "stock": 750,
"unit": "kilogram", "gst_rate": 0.08, "is_active": true
}],
"customers": [],
"retired_product_ids": []
}
is_delta is the dangerous field. false means the terminal withdraws
every product the response does not mention. A filtered result labelled false
empties the shelf. In Catalogue() the filter and the flag are derived from one
value, so no code path can set one without the other.
Anything ambiguous resolves toward the snapshot: a revision that is malformed, empty, or issued to a different outlet yields a full response.
The revision only advances on the final page, so a terminal that abandons a paginated pull cannot end up holding one claiming it saw pages it never got.
A delta cannot withdraw a deleted product — removing a row from
productlocations leaves no tombstone. Only a snapshot collects those, so tills
should pull without a revision periodically.
5. MQTT topics
| Topic | Direction | Retained |
|---|---|---|
nearle/pos/{loc}/{terminal}/order |
till → us | no |
nearle/pos/{loc}/{terminal}/customer |
till → us | no |
nearle/pos/{loc}/{terminal}/health |
till → us, 30s | no |
nearle/pos/{loc}/{terminal}/ack |
us → till | no |
nearle/pos/{loc}/{terminal}/status |
till → us | yes (Last Will) |
nearle/pos/{loc}/catalogue |
us → all tills at a shop | yes |
{loc} is the numeric tenantlocations.locationid. The tenant is resolved from
it server-side and never taken from the wire.
Namespaced under nearle/ alongside the rider fleet's nearle/riders/….
6. Database
pos_orders — one row per counter bill. Separate from orders because a
bill carries a cashier, terminal, rounding, promos, loyalty and a payment split
that orders has nowhere to put.
pos_order_items — one row per line.
productstocks — stock is not separate. A counter sale writes the same
out rows an app order does, through helpers in repositories/stockLedger.go.
Two stock ledgers would mean the catalogue pull sends a till figures that ignore
its own trading.
customers — registrations, matched on contactno.
Redis — pos:terminal:{code} (hash, 90s TTL) and
pos:location:{id}:terminals (set, no TTL). Namespaced pos:* so they cannot
collide with express's delivery:*, city:*, rider_*.
Reporting: counter sales are unioned into
GetRevenueSummaryandGetSalesSummary. Any new report must do the same, or it will silently understate every shop that runs a till. That is the standing cost of the split.
7. Code map
| File | |
|---|---|
models/pos.go |
wire types — matches the till's JSON exactly |
models/posorder.go |
pos_orders / pos_order_items |
models/poshealth.go |
heartbeat |
repositories/posRepository.go |
ingest + catalogue |
repositories/posSalesRepository.go |
the GET reads |
repositories/posPresence.go |
Redis presence |
repositories/stockLedger.go |
shared stock helpers |
messaging/posmqtt.go |
MQTT consumer |
messaging/posworkers.go |
bounded worker pools |
controllers/posController.go |
HTTP handlers |
routes/posroutes.go |
routes |
8. Operational notes
Only fiesta-0 consumes. MQTT has no queue groups, so all replicas would
receive every message and commit the same bill three times. Ordinal 0 is
elected; the others log "not the elected consumer". Override with
POS_MQTT_CONSUMER=always|never.
Worker pools: POS_INGEST_WORKERS (default 8), POS_HEALTH_WORKERS
(default 2). Heartbeats have their own pool so a backlog of bills cannot make
every till look dark at the busiest moment. A full queue blocks, pushing
backpressure to the broker and the till — slow, never lossy.
Startup check: three pos: subscribed to nearle/pos/+/+/… lines on
fiesta-0. Without them, MQTT ingest is not running and tills queue silently.
The broker is not durable storage. Mosquitto's max_queued_messages is 1000
and it flushes every 30 minutes. Fine, because a till keeps its copy until we
acknowledge — but nobody may ever ack on the broker's behalf.
9. Known gaps
- No product prices. Every product at loc 1135 is ₹0, so nothing is
sellable. Unpriced products come down as
is_active: falseso a till cannot ring up a ₹0 item. - The Flutter app has never been run. All testing used a Go program impersonating a till.
- No TLS on port 1883. Bills carry customer names and mobile numbers.
- No device authentication. A terminal is trusted with a location id.
- Loyalty does not come back down. Balances at a till are that till's view.
productstocks.quantityis an integer but tills sell in kg. POS rounds up so it never under-deducts; the app-order path truncates, which was left alone rather than silently changed. Making the column numeric is the real fix.- Broker credentials in source —
adminis in the rider APK and still unrestricted. The two POS accounts are the only ones not in a source tree.