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

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 sincefull snapshot. With a valid sincechange 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.

Redispos: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 sourceadmin is in the rider APK and still unrestricted. The two POS accounts are the only ones not in a source tree.