Files
backend_fiesta/POS_API.md
Suriya 8aa4d86eb6 Add a POS integration handover
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>
2026-08-03 20:22:11 +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.