Files
backend_fiesta/docs/POS_API.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

459 lines
15 KiB
Markdown

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