From 8aa4d86eb6e2745ba551e4dbda49d81240fec4d4 Mon Sep 17 00:00:00 2001 From: Suriya Date: Mon, 3 Aug 2026 20:22:11 +0530 Subject: [PATCH] Add a POS integration handover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- POS_API.md | 458 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 POS_API.md diff --git a/POS_API.md b/POS_API.md new file mode 100644 index 0000000..2cc8d27 --- /dev/null +++ b/POS_API.md @@ -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.