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