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>
This commit is contained in:
458
docs/POS_API.md
Normal file
458
docs/POS_API.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# POS integration — handover
|
||||
|
||||
Everything a developer needs to work on, extend or debug the in-store POS
|
||||
integration. Companion to [`POS_TERMINAL_INGEST.md`](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`
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```json
|
||||
{ "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
|
||||
|
||||
```bash
|
||||
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 |
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
```bash
|
||||
curl "$BASE/sales/summary?locationid=1135&fromdate=2026-08-01&todate=2026-08-03"
|
||||
```
|
||||
|
||||
Takes the same filters as `/sales`.
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
```bash
|
||||
curl "$BASE/health/terminal?terminal_id=T4A9"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
```bash
|
||||
curl "$BASE/health/location?location_id=1135"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"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.
|
||||
|
||||
```bash
|
||||
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**.
|
||||
|
||||
```json
|
||||
{
|
||||
"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 `GetRevenueSummary` and
|
||||
> `GetSalesSummary`. **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: false` so 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.quantity` is 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** — `admin` is in the rider APK and still
|
||||
unrestricted. The two POS accounts are the only ones not in a source tree.
|
||||
Reference in New Issue
Block a user