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>
This commit is contained in:
266
docs/CATALOGUE_IMPORT_INTEGRATION.md
Normal file
266
docs/CATALOGUE_IMPORT_INTEGRATION.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# Store Catalogue Import — Frontend Integration Spec
|
||||
|
||||
Backend work is done and verified live. This doc is the handoff: build the
|
||||
Admin Catalogue UI flow (browse global catalogue → choose products → import
|
||||
into a specific store) against the endpoints below.
|
||||
|
||||
## 1. Architecture (why the API looks like this)
|
||||
|
||||
There are two separate Postgres databases that never talk to each other
|
||||
directly:
|
||||
|
||||
- **CatalogueDB** (pgvector) — the global catalogue, one table per brand
|
||||
(`brand_dabur`, `brand_nestle`, `brand_pepsico`, `brand_sakthi`,
|
||||
`brand_manna`, `brand_naga`). ~237 products total today.
|
||||
- **nearledb** — your tenant/store data (`products`, `productlocations`,
|
||||
`productstocks`).
|
||||
|
||||
The backend bridges them using a **composite key: `(brand, catalogueid)`**.
|
||||
A catalogue row's bare `id` is only unique *within its own brand table* —
|
||||
`brand_dabur.id=1` and `brand_nestle.id=1` are different products. Every
|
||||
call that references a catalogue product must send both `brand` and
|
||||
`catalogueid`, never just an id.
|
||||
|
||||
When a product is imported, the backend snapshots it into the tenant's own
|
||||
`products` table (tagged with that brand+catalogueid) and links it to the
|
||||
location via the existing stock/location system. After that, it behaves
|
||||
exactly like a product the tenant created by hand — reading a store's
|
||||
catalogue never touches CatalogueDB again.
|
||||
|
||||
## 2. Endpoints
|
||||
|
||||
Base path: `/live/api/v1` (replace host with your environment's API host).
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/web/catalogue/getproducts` | Browse the global catalogue. Query: `brand` (**optional** — omit to search all brands merged), `category`, `keyword`, `pageno`, `pagesize`. This is the "show everything" entry point. |
|
||||
| `GET` | `/web/catalogue/getbrands` | List brands with product counts, for a brand filter chip row. |
|
||||
| `GET` | `/web/products/getimportedcatalogueproducts` | Query: `tenantid` (required), `brand` (**optional** — omit to check across every brand). Returns `[{brand, catalogueid}, …]` already imported by this tenant, for badging "Imported" in the browser. |
|
||||
| `GET` | `/web/products/getproductsubcategories` | Query: `tenantid`, `categoryid`. Use to populate the category/subcategory picker shown before import (see §4). |
|
||||
| `POST` | `/web/products/importcatalogueproduct` | Body is an **array** — import one or many in a batch. Idempotent: re-importing the same `(tenantid, brand, catalogueid)` tops up stock and updates price instead of duplicating. |
|
||||
| `GET` | `/web/products/getlocationproducts` | Query: `tenantid`, `locationid`, `pageno`, `pagesize`. The store's own catalogue view — what's actually imported. |
|
||||
| `DELETE` | `/web/products/deleteproductlocation` | Body: `tenantid`, `locationid`, `productid`. Unlinks from the store; keeps the product row and order history intact (safely re-importable after). |
|
||||
|
||||
Mobile mirrors exist at `/mob/products/importcatalogueproduct` and
|
||||
`/mob/products/getimportedcatalogueproducts` if the mobile app needs this
|
||||
flow too.
|
||||
|
||||
## 3. Integration flow
|
||||
|
||||
Order matters — each step depends on data fetched in the one before it.
|
||||
|
||||
1. **Show everything first.** Call `catalogue/getproducts` with no `brand`.
|
||||
That's the full catalogue, merged and paginated. Don't gate the list
|
||||
behind a brand selector — brand/category/keyword are filters applied on
|
||||
top of an already-visible list, not a prerequisite to seeing it.
|
||||
2. **Mark what's already imported.** Call
|
||||
`products/getimportedcatalogueproducts?tenantid=` (no `brand`, since the
|
||||
list mixes brands) in parallel with step 1. Build a lookup keyed on
|
||||
`` `${brand}:${catalogueid}` `` and badge matching items as "Imported".
|
||||
3. **Collect what the catalogue can't supply.** The catalogue has no exact
|
||||
price (only a `price_range` display string) and no mapping to this
|
||||
tenant's own categories. Before enabling the Import action on a product,
|
||||
require the store owner to pick `categoryid`/`subcategoryid` (from
|
||||
`getproductsubcategories`) and enter `retailprice`/`productcost`/`taxpercent`.
|
||||
4. **Import.** `POST products/importcatalogueproduct` with the batch. On
|
||||
success, invalidate both the imported-refs query and the store-catalogue
|
||||
query.
|
||||
5. **Show it in the store.** Refetch `products/getlocationproducts` — the
|
||||
imported item now appears like any other product, with live stock
|
||||
computed from the stock ledger.
|
||||
6. **Remove, if needed.** `DELETE products/deleteproductlocation`, then
|
||||
invalidate the same two queries as import.
|
||||
|
||||
## 4. Code
|
||||
|
||||
TypeScript + TanStack Query (React Query), matching the existing admin app
|
||||
pattern of invalidating queries after mutations.
|
||||
|
||||
### `api/catalogue.ts`
|
||||
|
||||
```ts
|
||||
const API_BASE = "https://<host>/live/api/v1";
|
||||
|
||||
export interface CatalogueProduct {
|
||||
id: number;
|
||||
brand: string;
|
||||
product_name: string;
|
||||
category?: string;
|
||||
images?: string[];
|
||||
size?: string;
|
||||
product_sku?: string;
|
||||
price_range?: string; // display only — never an exact price
|
||||
}
|
||||
|
||||
// brand omitted → the entire catalogue, all brands merged.
|
||||
export async function getCatalogueProducts(opts: {
|
||||
brand?: string; keyword?: string; pageno?: number; pagesize?: number;
|
||||
} = {}) {
|
||||
const { brand, keyword, pageno = 1, pagesize = 50 } = opts;
|
||||
const url = new URL(`${API_BASE}/web/catalogue/getproducts`);
|
||||
if (brand) url.searchParams.set("brand", brand);
|
||||
if (keyword) url.searchParams.set("keyword", keyword);
|
||||
url.searchParams.set("pageno", String(pageno));
|
||||
url.searchParams.set("pagesize", String(pagesize));
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
return { products: json.details as CatalogueProduct[], total: json.total as number };
|
||||
}
|
||||
|
||||
export interface ImportedRef { brand: string; catalogueid: number; }
|
||||
|
||||
// brand omitted → imported refs across every brand.
|
||||
export async function getImportedCatalogueRefs(tenantid: number, brand?: string) {
|
||||
const url = new URL(`${API_BASE}/web/products/getimportedcatalogueproducts`);
|
||||
url.searchParams.set("tenantid", String(tenantid));
|
||||
if (brand) url.searchParams.set("brand", brand);
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
const refs = json.details as ImportedRef[];
|
||||
return new Set(refs.map((r) => `${r.brand}:${r.catalogueid}`));
|
||||
}
|
||||
|
||||
export interface ImportCatalogueProductRequest {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
brand: string; // bridge key part 1
|
||||
catalogueid: number; // bridge key part 2 — the catalogue row's `id`
|
||||
categoryid: number; // this tenant's own category
|
||||
subcategoryid: number; // this tenant's own subcategory
|
||||
quantity: number;
|
||||
stocktype: "in" | "out";
|
||||
status: string;
|
||||
retailprice: number;
|
||||
productcost: number;
|
||||
taxpercent: number;
|
||||
}
|
||||
|
||||
export async function importCatalogueProducts(items: ImportCatalogueProductRequest[]) {
|
||||
const res = await fetch(`${API_BASE}/web/products/importcatalogueproduct`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(items),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.status) throw new Error(json.message);
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function removeFromStoreCatalogue(tenantid: number, locationid: number, productid: number) {
|
||||
const res = await fetch(`${API_BASE}/web/products/deleteproductlocation`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tenantid, locationid, productid }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
```
|
||||
|
||||
### `hooks/useCatalogueImport.ts`
|
||||
|
||||
```ts
|
||||
export function useCatalogueProducts(brand?: string, keyword?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["catalogue", "products", brand ?? "all", keyword ?? ""],
|
||||
queryFn: () => getCatalogueProducts({ brand, keyword, pagesize: 100 }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportedCatalogueRefs(tenantid: number, brand?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["catalogue", "imported", tenantid, brand ?? "all"],
|
||||
queryFn: () => getImportedCatalogueRefs(tenantid, brand),
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportCatalogueProduct(tenantid: number, locationid: number) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (items: ImportCatalogueProductRequest[]) => importCatalogueProducts(items),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["catalogue", "imported", tenantid] });
|
||||
queryClient.invalidateQueries({ queryKey: ["store-catalogue", tenantid, locationid] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Component usage
|
||||
|
||||
```tsx
|
||||
// brand starts undefined: the screen opens showing the whole catalogue.
|
||||
// Selecting a brand chip narrows it — it's a filter, never a gate.
|
||||
function CatalogueBrowser({ tenantid, locationid }: Props) {
|
||||
const [brand, setBrand] = useState<string | undefined>(undefined);
|
||||
const { data } = useCatalogueProducts(brand);
|
||||
const products = data?.products ?? [];
|
||||
const { data: imported = new Set<string>() } = useImportedCatalogueRefs(tenantid);
|
||||
const importProduct = useImportCatalogueProduct(tenantid, locationid);
|
||||
|
||||
function handleImport(product: CatalogueProduct, form: ImportForm) {
|
||||
importProduct.mutate([{
|
||||
tenantid, locationid,
|
||||
brand: product.brand,
|
||||
catalogueid: product.id,
|
||||
categoryid: form.categoryid,
|
||||
subcategoryid: form.subcategoryid,
|
||||
quantity: form.quantity,
|
||||
stocktype: "in",
|
||||
status: "Active",
|
||||
retailprice: form.retailprice,
|
||||
productcost: form.productcost,
|
||||
taxpercent: form.taxpercent,
|
||||
}]);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{products.map((p) => (
|
||||
<li key={`${p.brand}:${p.id}`}>
|
||||
{p.product_name} ({p.brand})
|
||||
{imported.has(`${p.brand}:${p.id}`)
|
||||
? <span className="badge">Imported</span>
|
||||
: <ImportButton onImport={(form) => handleImport(p, form)} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Gotchas
|
||||
|
||||
- **Always send `brand` with `catalogueid`.** Ids repeat across brands;
|
||||
either one alone is ambiguous.
|
||||
- **Category/subcategory must already exist for the tenant.** There's no
|
||||
automatic mapping from the catalogue's free-text `category` string to
|
||||
this tenant's `categoryid`/`subcategoryid` yet — the UI must require a
|
||||
pick from `getproductsubcategories` before enabling Import.
|
||||
- **Price is store-set, not catalogue-set.** The catalogue only has a
|
||||
`price_range` display string. `retailprice`/`productcost`/`taxpercent`
|
||||
always come from the store owner's input.
|
||||
- **Re-importing tops up, it doesn't duplicate.** Same
|
||||
`(tenantid, brand, catalogueid)` twice reuses the same product row:
|
||||
quantity adds via the stock ledger, price fields overwrite with whatever
|
||||
was sent that call.
|
||||
- **Delete unlinks, it doesn't erase.** The product row (and any order
|
||||
history referencing it) survives; the item becomes instantly
|
||||
re-importable.
|
||||
- **Known brands today:** `dabur`, `nestle`, `pepsico`, `sakthi`, `manna`,
|
||||
`naga` — pull the live list from `getbrands` rather than hardcoding it.
|
||||
|
||||
## 6. Implementation checklist
|
||||
|
||||
- [ ] API client functions (§4) added to the frontend's API layer, with
|
||||
`API_BASE` pointed at the real environment host.
|
||||
- [ ] Catalogue browse screen: loads with no brand filter (all products),
|
||||
brand/category/keyword as UI filters on top.
|
||||
- [ ] Already-imported badge wired to `getImportedCatalogueRefs`, keyed on
|
||||
`brand:catalogueid`.
|
||||
- [ ] Import action collects `categoryid`, `subcategoryid`, `retailprice`,
|
||||
`productcost`, `taxpercent` from the user before enabling submit.
|
||||
- [ ] Import mutation invalidates both the imported-refs query and the
|
||||
store-catalogue query on success.
|
||||
- [ ] Store catalogue screen (`getlocationproducts`) reflects imports
|
||||
immediately after the above invalidation.
|
||||
- [ ] Remove action wired to `deleteproductlocation`, same invalidation.
|
||||
124
docs/MOBILE_ORDER_VERIFICATION.md
Normal file
124
docs/MOBILE_ORDER_VERIFICATION.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Order Creation — Mobile Developer Verification Guide
|
||||
|
||||
## Why this exists
|
||||
|
||||
We found that orders placed through the app were being saved with **zero line
|
||||
items** — the order header (tenant, location, customer) saved fine, but the
|
||||
`items` array was silently getting dropped somewhere between the app and the
|
||||
database. Because the stock check only runs over whatever's in `items`, an
|
||||
order with no items also skipped stock validation entirely.
|
||||
|
||||
The backend now tolerates a few different request shapes and has a stock
|
||||
check in place, but the app's actual request needs to be verified against
|
||||
what's below to confirm it lines up.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST https://fiesta.nearle.app/live/api/v1/mob/orders/createorder
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
## Request shape — items MUST be inside `orders`, not a sibling of it
|
||||
|
||||
**Correct:**
|
||||
```json
|
||||
{
|
||||
"orders": {
|
||||
"tenantid": 1135,
|
||||
"locationid": 1166,
|
||||
"customerid": 42,
|
||||
"items": [
|
||||
{ "productid": 7060, "orderqty": 2, "price": 45.0 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Also accepted (flat, no wrapper):**
|
||||
```json
|
||||
{
|
||||
"tenantid": 1135,
|
||||
"locationid": 1166,
|
||||
"customerid": 42,
|
||||
"items": [
|
||||
{ "productid": 7060, "orderqty": 2, "price": 45.0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**This shape used to silently lose the items — avoid it:**
|
||||
```json
|
||||
{
|
||||
"orders": { "tenantid": 1135, "locationid": 1166 },
|
||||
"items": [ { "productid": 7060, "orderqty": 2 } ]
|
||||
}
|
||||
```
|
||||
`items` as a sibling of `orders` (not nested inside it) is now handled as a
|
||||
fallback server-side too, but don't rely on the fallback — put `items` inside
|
||||
`orders` to match the primary/documented shape.
|
||||
|
||||
## Required fields per item
|
||||
|
||||
| field | type | required | notes |
|
||||
|-------------|--------|----------|-------------------------------------------|
|
||||
| `productid` | int | yes | must be a real product for the tenant |
|
||||
| `orderqty` | number | yes | quantity being ordered |
|
||||
| `price` | number | recommended | unit price at time of order |
|
||||
| `locationid`| int | no | defaults to the order's own `locationid` if omitted |
|
||||
|
||||
## Expected responses — verify your app handles all of these
|
||||
|
||||
| Scenario | HTTP code | Body (key fields) |
|
||||
|---|---|---|
|
||||
| Order succeeds | `200` | `"status": true`, `"details": { "orderheaderid": ..., "items": [...] }` |
|
||||
| No `tenantid` at all | `409` | `"status": false`, `"message": "Tenant ID is required"` |
|
||||
| `items` missing/empty | `400` | `"status": false`, `"message": "Order must contain at least one item"` |
|
||||
| Requested qty > available stock | `409` | `"status": false`, `"message": "insufficient stock for product '<name>': requested X, available Y"` |
|
||||
|
||||
**Important:** a `409` with "insufficient stock" is not a network/server
|
||||
error — it's the correct, expected response when a customer tries to order
|
||||
more than what's in stock at that store. The app should catch this
|
||||
specifically (check the message text, or treat any `409` from this endpoint
|
||||
as a stock problem) and show the customer a clear "not enough stock" message
|
||||
rather than a generic error screen.
|
||||
|
||||
## How to verify end-to-end yourselves
|
||||
|
||||
1. Pick a real `tenantid` + `locationid` + `productid` combo you know has
|
||||
stock (ask backend/ops for current numbers, or check via the merchant
|
||||
web app's inventory view).
|
||||
2. Place a normal order for 1 unit through the app. Confirm it returns `200`
|
||||
and the response's `details.items` array is non-empty.
|
||||
3. Place an order for a quantity larger than what's currently in stock for
|
||||
that product/location. Confirm you get a `409` with an "insufficient
|
||||
stock" message, and that the app surfaces this to the user instead of
|
||||
silently failing or showing a generic error.
|
||||
4. Cancel a successful order and confirm a follow-up stock check reflects
|
||||
the restored quantity (ask backend to check, or place the same
|
||||
over-quantity order again afterward — it should now succeed if
|
||||
cancellation restored enough stock).
|
||||
|
||||
## Checking stock before the customer even taps "order"
|
||||
|
||||
`GET /live/api/v1/mob/products/getproductbyvariant` now accepts an optional
|
||||
`locationid` query param:
|
||||
|
||||
```
|
||||
GET /live/api/v1/mob/products/getproductbyvariant?tenantid=1135&variantid=44&locationid=1166
|
||||
```
|
||||
|
||||
When `locationid` is passed, each returned product now carries two extra
|
||||
live fields:
|
||||
|
||||
| field | meaning |
|
||||
|---|---|
|
||||
| `productstock` | live available quantity at that store — same SUM(in)-SUM(out) formula the order stock check uses |
|
||||
| `locationstatus` | that store's status for this product, e.g. `"outofstock"` or `"available"`/`"Active"` |
|
||||
|
||||
**If `locationid` is omitted, both fields come back empty/zero** — this is
|
||||
the old behavior preserved for backward compatibility, not new stock data.
|
||||
Start passing `locationid` (the store the customer is browsing) to get real
|
||||
numbers, and use it to show "out of stock" / gray out the add-to-cart button
|
||||
*before* the customer tries to order, instead of only finding out from the
|
||||
`409` response above.
|
||||
458
docs/POS_API.md
Normal file
458
docs/POS_API.md
Normal file
@@ -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.
|
||||
591
docs/POS_LOGIN.md
Normal file
591
docs/POS_LOGIN.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# Nearle POS — Terminal Sign-In
|
||||
|
||||
How a till authenticates, and how it finds out which shop it belongs to.
|
||||
|
||||
**Base URL** `https://fiesta.nearle.app/live/api/v1/pos`
|
||||
**Live since** 6 Aug 2026, `v1.3.98`
|
||||
|
||||
---
|
||||
|
||||
## What changed, and why it matters
|
||||
|
||||
A terminal used to hold a store id typed into Settings and a password compiled
|
||||
into the app. That made the store id a **claim** rather than a fact: any till
|
||||
could name any outlet and be believed, so changing one number on one screen
|
||||
moved a terminal into another tenant's books. The password was identical on
|
||||
every install of a build.
|
||||
|
||||
Now a person signs in with their own back-office account, and the outlet
|
||||
arrives **as a consequence** — sealed inside a signed token the terminal cannot
|
||||
edit, and re-checked by the server on every request.
|
||||
|
||||
The rule to hold onto: **the till no longer decides which shop it is. It is
|
||||
told.**
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
BASE=https://fiesta.nearle.app/live/api/v1/pos
|
||||
|
||||
# 1. Sign in
|
||||
curl -s -X POST $BASE/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"authname":"rsselvapuram@gmail.com","password":"…","terminal_id":"T5EDD"}'
|
||||
|
||||
# 2. Use the token on everything else
|
||||
curl -s $BASE/session -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The flow
|
||||
|
||||
These steps are in order, and the order matters.
|
||||
|
||||
**1. Sign in.** `POST /login` with the operator's own credentials — the same
|
||||
`app_users` account they use for the web console. There is no separate POS
|
||||
password.
|
||||
|
||||
**2. Read `store_id` out of the response.** Do not ask anyone to type it. It is
|
||||
whatever the back office says that account's outlet is.
|
||||
|
||||
**3. If `locations` has more than one entry, ask which one.** Only then. A
|
||||
single-outlet account gets a list of one and must never see a picker.
|
||||
|
||||
**4. Save the token.** Platform keystore, not a plain file or SQLite — it is a
|
||||
bearer credential for a whole trading day. Restore it on launch **before** any
|
||||
upload or catalogue pull runs.
|
||||
|
||||
**5. Send it on every request** as `Authorization: Bearer <token>`.
|
||||
|
||||
**6. Import `staff`.** Replace the till's local staff with what came down, and
|
||||
deactivate anything that wasn't in the list. That is what retires the built-in
|
||||
PINs.
|
||||
|
||||
---
|
||||
|
||||
## `POST /login`
|
||||
|
||||
The only unauthenticated route. It is where a token comes from.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"authname": "rsselvapuram@gmail.com",
|
||||
"password": "…",
|
||||
"terminal_id": "T5EDD",
|
||||
"device_id": "a5f3…",
|
||||
"location_id": 1135,
|
||||
"configid": 1
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Notes |
|
||||
|---|---|---|
|
||||
| `authname` | yes* | Email. **Or** send `contactno` instead. |
|
||||
| `contactno` | yes* | Mobile number, as an alternative to `authname`. |
|
||||
| `password` | yes | |
|
||||
| `terminal_id` | no | This till's short code, e.g. `T5EDD`. Recorded on the session. |
|
||||
| `device_id` | no | The device's stable UUID. |
|
||||
| `location_id` | no | **Only** meaningful for a multi-outlet account. A request, not an assertion — it is checked against what the account may reach. |
|
||||
| `configid` | no | Inferred when absent. Send it only if you get the ambiguity error below. |
|
||||
|
||||
\* one of `authname` or `contactno`.
|
||||
|
||||
### Response — `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"status": true,
|
||||
"message": "Login successful",
|
||||
"details": {
|
||||
"token": "eyJ1aWQiOjEy….K3p9",
|
||||
"expires_at": "2026-09-05T10:51:17Z",
|
||||
|
||||
"user_id": 1229,
|
||||
"full_name": "Selvapuram",
|
||||
"email": "rsselvapuram@gmail.com",
|
||||
"role_id": 0,
|
||||
|
||||
"tenant_id": 1087,
|
||||
"tenant_name": "Ragul Stores",
|
||||
|
||||
"store_id": "1135",
|
||||
"location_id": 1135,
|
||||
"location_name": "Ragul stores Selvapuram",
|
||||
"gstin": "123456",
|
||||
"address": "…",
|
||||
"phone": "…",
|
||||
|
||||
"locations": [
|
||||
{ "location_id": 1135, "location_name": "Ragul stores Selvapuram",
|
||||
"address": "", "city": "", "status": "Active" }
|
||||
],
|
||||
|
||||
"staff": [
|
||||
{ "user_id": 1148, "full_name": "Ragul Kannan",
|
||||
"role": "Super admin", "pin": "1111", "status": "Active" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### The fields that matter
|
||||
|
||||
**`store_id`** — a string, because that is the shape every uplink already
|
||||
sends. Use it verbatim as the `store_id` on `/orders`, `/customers` and
|
||||
`/catalogue`. It is the same value as `location_id`, handed back in the form it
|
||||
will be replayed in.
|
||||
|
||||
**`token`** — **opaque**. Do not parse it, do not read anything out of it, do
|
||||
not trust anything it appears to say. Its only correct use is to hand it back.
|
||||
|
||||
**`expires_at`** — 30 days out. Long on purpose: a shop signs a terminal in once
|
||||
and expects it to keep working. Forcing a re-login mid-shift means a queue of
|
||||
customers waiting while somebody finds the manager.
|
||||
|
||||
**`gstin` / `address` / `phone`** — print these on the receipt. They are a legal
|
||||
requirement on a GST invoice and they used to be compile-time constants, so a
|
||||
shop correcting its GSTIN had to wait for a rebuild. Write them locally on
|
||||
sign-in.
|
||||
|
||||
**`locations`** — every outlet this account may open a till at. Length 1 is the
|
||||
normal case.
|
||||
|
||||
**`staff`** — see [Staff and PINs](#staff-and-pins). **Often empty.**
|
||||
|
||||
---
|
||||
|
||||
## `GET /session`
|
||||
|
||||
Answers who the caller is, per their token. What a till calls on launch to
|
||||
check whether yesterday's session is still good, without making a real request
|
||||
and interpreting the failure.
|
||||
|
||||
Requires the token. Returns `401` when there isn't one.
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"status": true,
|
||||
"details": {
|
||||
"user_id": 1229,
|
||||
"tenant_id": 1087,
|
||||
"location_id": 1135,
|
||||
"store_id": "1135",
|
||||
"role_id": 0,
|
||||
"terminal_id": "PROBE",
|
||||
"expires_at": "2026-09-05T10:51:17Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `GET /staff`
|
||||
|
||||
Who may ring a bill at this terminal's outlet. For pulling down somebody hired
|
||||
mid-shift without signing the terminal out.
|
||||
|
||||
**Takes no parameters.** The answer carries PINs, so the outlet comes from the
|
||||
caller's own token — a till must not be able to ask who works at the shop next
|
||||
door. A request without a token is refused whatever the enforcement setting is.
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"status": true,
|
||||
"details": {
|
||||
"location_id": 1135,
|
||||
"staff": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roles
|
||||
|
||||
Two POS roles, added to `app_roles`:
|
||||
|
||||
| roleid | Role | Can |
|
||||
|---|---|---|
|
||||
| `7` | **Supervisor** | everything a till does, **plus** creating and editing counter staff |
|
||||
| `8` | **Cashier** | billing only |
|
||||
|
||||
The session carries both, so the terminal never has to map role ids itself:
|
||||
|
||||
```json
|
||||
{ "role_id": 7, "role": "Supervisor", "can_manage_staff": true }
|
||||
```
|
||||
|
||||
Branch on `can_manage_staff`, not on the number. `app_roles` holds six rows for
|
||||
four back-office roles (Admin is both 3 and 5, Manager both 4 and 6) and most
|
||||
accounts carry an id that is not in the table at all — any mapping written on
|
||||
the terminal would be wrong.
|
||||
|
||||
### The till and Nearle Daily do not share accounts
|
||||
|
||||
`app_users` is the only thing the two products have in common. An account
|
||||
belongs to one or the other, never to both:
|
||||
|
||||
| | Nearle Daily app + console | POS terminal |
|
||||
|---|---|---|
|
||||
| roles | `1`–`6` — Super admin, Operations, Admin, Manager | `7` Supervisor, `8` Cashier |
|
||||
| `/applogin`, `/tenant/weblogin`, `/tenant/login` | yes | **not found** |
|
||||
| `POST /v1/pos/login` | **403** | yes |
|
||||
| listed by `/getallusers`, `/getstaffs` | yes | **hidden** |
|
||||
|
||||
A Nearle Daily **Super admin is not the administrator of anybody's POS.** The
|
||||
back office reaches a till by *provisioning* a Supervisor from the console; it
|
||||
never becomes one by signing in.
|
||||
|
||||
This was the other way round until it was measured. Roles 1–6 counted as
|
||||
supervisors, on the reasoning that somebody who already administers a shop from
|
||||
a browser is not made less privileged by standing at the counter. That handed
|
||||
till-supervisor powers to **68 live accounts, 59 of them platform Super
|
||||
admins**, while the actual shop accounts carry `roleid 0` and were refused.
|
||||
|
||||
Both directions are now closed in the queries themselves rather than in a check
|
||||
each call site has to remember — a till account is not *rejected* by the app
|
||||
login, it is simply not found.
|
||||
|
||||
**`role_id` 0 is not a role.** It is what an account carries when nobody set
|
||||
one, 22 live accounts have it including a delivery rider, and it grants nothing
|
||||
on either side.
|
||||
|
||||
### A Supervisor needs a password, a Cashier does not
|
||||
|
||||
A PIN cannot open a *closed* terminal — `/pos/login/pin` requires a session that
|
||||
already exists. So a Supervisor is provisioned with an `authname` **and** a
|
||||
`password` as well as a PIN, and a Cashier gets only a PIN: a cashier signs on
|
||||
at a counter a Supervisor has already opened, so a second password would be one
|
||||
more credential to leak for no capability gained.
|
||||
|
||||
Provisioning a Cashier and nobody else leaves an outlet with no way in at all.
|
||||
|
||||
---
|
||||
|
||||
## `POST /pos/login/pin` — signing on at an open terminal
|
||||
|
||||
For a cashier taking over a counter a supervisor has already opened.
|
||||
|
||||
**Requires an existing valid token.** That is the security model, not an
|
||||
oversight: four digits is ten thousand guesses, which is no barrier at all to an
|
||||
anonymous caller. Tying it to a session means a supervisor has opened the
|
||||
terminal with a real password first, and the guesses are confined to that one
|
||||
outlet's staff.
|
||||
|
||||
```bash
|
||||
curl -s -X POST $BASE/login/pin \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"pin":"1602"}'
|
||||
```
|
||||
|
||||
Returns a **new** session, with the same shape as `/login`. New rather than
|
||||
reused, because the token carries the role — a cashier taking over from a
|
||||
supervisor must drop their permissions, not inherit them.
|
||||
|
||||
`401` if the PIN is not recognised. `400` if two people at the outlet share it,
|
||||
which creation refuses but older data may contain.
|
||||
|
||||
---
|
||||
|
||||
## `/pos/users` — the shop's own counter staff
|
||||
|
||||
A supervisor creates their own cashiers, from the terminal.
|
||||
|
||||
**The outlet is never in the request.** Tenant and location come from the
|
||||
caller's token, so a supervisor at Selvapuram cannot create staff at R mart by
|
||||
sending a different number — the same inversion that stopped a till naming its
|
||||
own store id.
|
||||
|
||||
### `POST /pos/users`
|
||||
|
||||
```json
|
||||
{
|
||||
"full_name": "Asha Kumar",
|
||||
"role": "cashier",
|
||||
"pin": "4821",
|
||||
"authname": "asha@shop.test",
|
||||
"password": "…"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `full_name` | required; split across `firstname`/`lastname` |
|
||||
| `role` | `"supervisor"` or `"cashier"`. Anything else is refused — never defaulted |
|
||||
| `pin` | 4 digits. See the rules below |
|
||||
| `password` + `authname` | optional; for someone who also signs the terminal in |
|
||||
|
||||
**At least one of `pin` or `password` is required.** Creating a person who can
|
||||
sign in by neither would look like it worked right up until somebody tried.
|
||||
|
||||
:warning: **PIN rules, and why**
|
||||
|
||||
- **Exactly 4 digits, and cannot start with `0`.** `app_users.pin` is a
|
||||
`bigint`, so `"0451"` would be stored as `451` and read back as three digits —
|
||||
a cashier would type four and be refused for ever. One such account already
|
||||
exists in live data.
|
||||
- **`1234`, `1111`, `2345`, `4321`, `9999`, `2222`, `3456`, `0000` are refused.**
|
||||
Live data has `1234` on eleven accounts and `1111` on nine.
|
||||
- **Unique within the outlet**, not globally. A PIN only distinguishes people at
|
||||
one counter; making it platform-unique would exhaust the space fast.
|
||||
|
||||
Answers `201` with the created user. Every failure is a `400` carrying the
|
||||
reason, because all of them are things the caller can fix.
|
||||
|
||||
### `GET /pos/users`
|
||||
|
||||
Readable by anyone signed in — the terminal needs it to show who is on shift.
|
||||
**A cashier gets the list with `pin` blanked**; only somebody who could set a
|
||||
PIN gets to see one. `?include_inactive=true` to see leavers.
|
||||
|
||||
### `PUT /pos/users`
|
||||
|
||||
Same fields plus `user_id`. Send only what changes. Supervisor only.
|
||||
|
||||
### `DELETE /pos/users?user_id=9189`
|
||||
|
||||
Deactivates — never deletes, because bills carry the cashier's name and shifts
|
||||
settle against it. Supervisor only, and you cannot deactivate the account you
|
||||
are signed in as: otherwise the last supervisor at a shop can lock everyone out
|
||||
with one tap.
|
||||
|
||||
---
|
||||
|
||||
## Creating staff from the web console
|
||||
|
||||
The same staff management, for the screen an admin actually uses. Registered
|
||||
under both `/v1/web/tenants` and `/v1/mob/tenants`.
|
||||
|
||||
```
|
||||
GET /v1/web/tenants/posroles
|
||||
GET /v1/web/tenants/getposusers?tenantid=1087&locationid=1135
|
||||
POST /v1/web/tenants/createposuser
|
||||
PUT /v1/web/tenants/updateposuser
|
||||
DELETE /v1/web/tenants/deleteposuser?tenantid=1087&locationid=1135&userid=9189
|
||||
```
|
||||
|
||||
`createposuser` takes the same body as `/pos/users`, plus the outlet — the
|
||||
console has no session token, so it has to name one:
|
||||
|
||||
```json
|
||||
{
|
||||
"tenantid": 1087,
|
||||
"locationid": 1135,
|
||||
"full_name": "Asha Kumar",
|
||||
"role": "cashier",
|
||||
"pin": "4821"
|
||||
}
|
||||
```
|
||||
|
||||
**These run the same service calls as `/pos/users`.** A supervisor created from
|
||||
a browser is the same row, with the same rules applied, as one created at a
|
||||
counter — same PIN validation, same duplicate check, same identity-column
|
||||
allocation. That is the point of them: two paths writing one table is how the
|
||||
two stop matching.
|
||||
|
||||
`configid` is never asked for. It is inferred from whichever value the tenant's
|
||||
existing accounts carry — a number nobody looks up, that varies per tenant (1087
|
||||
is spread across 1, 6 and 15), and that silently creates an account nobody can
|
||||
find if it is wrong.
|
||||
|
||||
`GET /posroles` returns the two roles with their ids and labels, so a console
|
||||
offering the choice never has to know that supervisor is `7`.
|
||||
|
||||
### :red_circle: These are unauthenticated
|
||||
|
||||
Like every other route in the `/v1/web` and `/v1/mob` groups — there is no auth
|
||||
middleware anywhere on the web API. The outlet is checked against the tenant
|
||||
before anything is written, so a caller cannot create staff at a shop that is
|
||||
not theirs *given a tenant id* — but nothing proves the caller is that tenant.
|
||||
|
||||
So this mints till credentials on an unauthenticated request. It is consistent
|
||||
with the rest of the platform, and it is still the weakest point in this design.
|
||||
They should move behind a session guard as soon as the console can hold one.
|
||||
|
||||
The terminal routes are not affected: `/pos/users` proves its outlet with a
|
||||
signed token.
|
||||
|
||||
---
|
||||
|
||||
## Using the token
|
||||
|
||||
```
|
||||
Authorization: Bearer eyJ1aWQiOjEy….K3p9
|
||||
```
|
||||
|
||||
`X-Pos-Token: <token>` is accepted as a fallback, because some shop routers
|
||||
strip `Authorization` headers over plain HTTP. A bare token with no `Bearer `
|
||||
prefix is tolerated too.
|
||||
|
||||
Send it on **every** POS call: `/orders`, `/customers`, `/catalogue`, `/health`,
|
||||
`/sales*`, `/session`, `/staff`.
|
||||
|
||||
### What the server checks
|
||||
|
||||
1. The token verifies against our signing key and has not expired.
|
||||
2. The outlet named in the request belongs to the token's tenant.
|
||||
|
||||
The second is the one that matters. A valid token is a licence to name **your**
|
||||
outlets, not any outlet. The outlet is read from the query string *and* from the
|
||||
JSON body, because `/orders` and `/customers` carry `store_id` in the batch and
|
||||
never in the URL.
|
||||
|
||||
```
|
||||
GET /catalogue?store_id=1135 → 200 your outlet
|
||||
GET /catalogue?store_id=1185 → 403 {"message":"this session cannot reach outlet 1185"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Errors
|
||||
|
||||
### Sign-in
|
||||
|
||||
| Code | Meaning | What the till should do |
|
||||
|---|---|---|
|
||||
| `400` | Body unreadable, or neither `authname` nor `contactno` sent | Fix the request |
|
||||
| `401` | `those sign-in details were not recognised` | Ask them to re-type. **Wrong email and wrong password give the same message** — deliberately, so the endpoint isn't a directory of who banks here |
|
||||
| `403` | Real account, but it can't open this till | Show the message; re-typing won't help |
|
||||
|
||||
The `403` messages, verbatim:
|
||||
|
||||
- `this account is not set up for the till; ask your store admin to add you as a Supervisor or Cashier in the web console`
|
||||
- `this account is inactive; contact your administrator`
|
||||
- `this account has no password set; set one in the web console first`
|
||||
- `this account is not attached to a tenant and cannot open a till`
|
||||
- `no active outlet is registered for this account`
|
||||
- `this account cannot open a till at outlet 1185`
|
||||
- `more than one account uses these sign-in details; ask your administrator for the configid and send it with the login`
|
||||
|
||||
That last one is real, not theoretical: `authname` is not unique in this schema.
|
||||
Live data has the same address twice. We refuse rather than pick one, because
|
||||
picking wrong means billing into another tenant's books.
|
||||
|
||||
The **first** one is the common case now, and it is deliberately specific where a
|
||||
bad password is deliberately vague. By the time it fires the caller has already
|
||||
proved the credential, so naming the reason leaks nothing they did not just
|
||||
demonstrate — and the vague answer would send a shop owner hunting for a
|
||||
password that was never wrong.
|
||||
|
||||
### Authenticated routes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `401` | No token, malformed token, bad signature, or expired — sign in again |
|
||||
| `403` | Valid token naming an outlet the tenant doesn't own |
|
||||
|
||||
---
|
||||
|
||||
## Multi-outlet accounts
|
||||
|
||||
An account pinned to one location gets that location. An account with no
|
||||
location — a proprietor with several shops — gets all of the tenant's active
|
||||
outlets.
|
||||
|
||||
```
|
||||
rsselvapuram@gmail.com → 1 outlet (1135, Selvapuram)
|
||||
raguladmin@gmail.com → 6 outlets (1097, 1135, 1137, 1138, 1139, 885536644)
|
||||
```
|
||||
|
||||
When `locations.length > 1`:
|
||||
|
||||
1. Show a picker. **Don't make it dismissable** — a terminal has to be standing
|
||||
somewhere, and silently defaulting to the first outlet is how a day's takings
|
||||
get filed against the wrong shop.
|
||||
2. Sign in **again** with `location_id` set to their choice.
|
||||
|
||||
Re-signing-in is not laziness. The outlet is inside the signed token, so only
|
||||
the server can issue one for a different shop — and re-checking entitlement at
|
||||
that moment is the point.
|
||||
|
||||
---
|
||||
|
||||
## Staff and PINs
|
||||
|
||||
Two different credentials, easily confused:
|
||||
|
||||
| | Says | Checked by |
|
||||
|---|---|---|
|
||||
| **Sign-in** (email + password) | which **shop** this terminal is | the server |
|
||||
| **PIN** | which **person** rang this bill | the terminal, offline |
|
||||
|
||||
The PIN stamps `cashiername` and is what shifts settle against. It is **shift
|
||||
attribution, not a security boundary** — the boundary is the token.
|
||||
|
||||
### The PIN comes down in the clear
|
||||
|
||||
Over TLS, and that's considered rather than sloppy. Four digits are
|
||||
brute-forceable in microseconds whatever they're wrapped in, so hashing
|
||||
server-side would buy the appearance of strength and not the substance — while
|
||||
costing something real, because the terminal salts every PIN with its own random
|
||||
salt before storing it and could never verify a hash computed on the server.
|
||||
|
||||
**Store it hashed on the device.** It arrives in the clear; it must not sit that
|
||||
way.
|
||||
|
||||
### Importing
|
||||
|
||||
Write everyone in `staff`, keyed on `user_id` so a re-sync updates rather than
|
||||
duplicates. Then **deactivate everything you didn't just import** — that is what
|
||||
kills the built-in PINs. Deactivate, never delete: bills carry the cashier's
|
||||
name.
|
||||
|
||||
### :warning: `staff` is usually empty today
|
||||
|
||||
Only 116 of 596 accounts on the platform have a PIN set. Outlet 1135 — the one
|
||||
the terminal ships pointed at — has **zero**.
|
||||
|
||||
So:
|
||||
|
||||
- **An empty list is not a failure.** Do nothing and leave the till exactly as
|
||||
it was.
|
||||
- **A list where every PIN is unusable** (`0`, blank) must behave the same way.
|
||||
Deactivating the local accounts because the back office isn't filled in yet
|
||||
would leave a counter nobody can sign in to.
|
||||
|
||||
The terminal still ships with three seeded logins for exactly this reason. They
|
||||
retire automatically the moment real staff exist. Filling in real PINs in the
|
||||
back office is what makes that happen.
|
||||
|
||||
---
|
||||
|
||||
## Current state
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Endpoints | live on `v1.3.98`, all three pods |
|
||||
| Signing key | set in `app-secrets` |
|
||||
| **Enforcement** | **OFF** — `POS_AUTH_REQUIRED` is unset |
|
||||
|
||||
Enforcement being off means a request carrying **no** token is still allowed
|
||||
through, so terminals already trading don't stop the day this ships. It does
|
||||
**not** mean tokens are ignored:
|
||||
|
||||
- a token that's present and invalid is **always** refused;
|
||||
- a valid token naming another tenant's outlet is **always** refused.
|
||||
|
||||
Once the fleet is on a build that signs in, `POS_AUTH_REQUIRED=true` closes the
|
||||
door on untokened requests.
|
||||
|
||||
---
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Passwords are stored in plaintext** across the whole platform, not just
|
||||
here. Fixing it is a migration touching every login path.
|
||||
- **No role check.** Any active account with a tenant, a password and an active
|
||||
outlet can open a till — including `roleid 0`, which isn't in `app_roles` at
|
||||
all and currently includes a delivery rider. The damage is bounded by the
|
||||
token: they can only reach their own tenant's books.
|
||||
- **`1135` means two different things.** It's a *location* (Ragul stores
|
||||
Selvapuram, under tenant 1087) and separately a *tenant* (Suriya Store). Same
|
||||
number, different tables. Watch for it in logs.
|
||||
362
docs/POS_TERMINAL_INGEST.md
Normal file
362
docs/POS_TERMINAL_INGEST.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# POS terminal ingest
|
||||
|
||||
How an in-store Nearle POS till reaches this backend. The terminal side of the
|
||||
contract is specified in the POS repository at `docs/sync-contract.md`; this
|
||||
covers what was built here and how to turn it on.
|
||||
|
||||
## What a terminal expects, and why it matters
|
||||
|
||||
A till stores every bill in its own SQLite database the moment a sale
|
||||
completes, and keeps it for **seven days after we acknowledge it**. It marks a
|
||||
bill synced if — and only if — the bill's id appears in the `accepted` list of
|
||||
our reply.
|
||||
|
||||
That single rule drives every decision below:
|
||||
|
||||
- **Silence is not acceptance.** No reply, an empty reply, 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, and it is why a failing ingest never
|
||||
acknowledges.
|
||||
- **A duplicate is a success.** Delivery is at-least-once. A lost ack makes a
|
||||
terminal re-send bills we already hold, and calling those failures would
|
||||
strand a day of takings. The ingest recognises them and accepts them without
|
||||
touching stock again.
|
||||
- **A rejection is a decision.** Naming an id in `rejected` stops the till
|
||||
retrying it and waits for a person. Right for "this bill is malformed", wrong
|
||||
for "the database is having a bad minute".
|
||||
|
||||
## Two ways in, one code path
|
||||
|
||||
Both transports call `services.PosService`, so a bill arriving over MQTT and
|
||||
one arriving over HTTP cannot diverge.
|
||||
|
||||
### Where a bill lands
|
||||
|
||||
Counter sales are written to **`pos_orders` / `pos_order_items`**, not to
|
||||
`orders`. A bill is a different document from an app order: it carries a
|
||||
cashier, a terminal, a rounding adjustment, promo campaigns, loyalty movement
|
||||
and a payment split across several tenders, none of which `orders` has anywhere
|
||||
to put. Forcing one into the other's shape loses whichever fields do not fit,
|
||||
silently.
|
||||
|
||||
**Stock is not separate.** A counter sale writes the same `productstocks`
|
||||
"out" rows an app order does, through the shared helpers in `stockLedger.go` —
|
||||
the same row locks, the same availability check, the same availability re-sync.
|
||||
Two stock ledgers would mean the catalogue pull sends a till figures that ignore
|
||||
the till's own trading, and it would oversell.
|
||||
|
||||
Existing revenue queries were extended to include `pos_orders`
|
||||
(`GetRevenueSummary`, `GetSalesSummary`), so dashboards do not understate a shop
|
||||
that runs a counter. **Any new report has to remember to do the same** — that is
|
||||
the standing cost of the split.
|
||||
|
||||
### HTTP
|
||||
|
||||
Base path: `/live/api/v1/pos`
|
||||
|
||||
**Written by a terminal** — bare-ack responses, see below.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/orders` | Completed bills |
|
||||
| `POST` | `/customers` | Shoppers registered at a till |
|
||||
| `GET` | `/catalogue` | Product pull. Query: `store_id`, `since`, `page`, `page_size` |
|
||||
|
||||
**Read by the web app** — normal `{code, message, status, details}` envelope.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/sales` | Bills for an outlet, newest first |
|
||||
| `GET` | `/sales/detail` | One bill with its lines |
|
||||
| `GET` | `/sales/summary` | Totals by tender, day and till |
|
||||
| `GET` | `/health/terminal` | One till's live state |
|
||||
| `GET` | `/health/location` | Every till at a shop |
|
||||
|
||||
`/sales` and `/sales/summary` take: **`locationid` (required)**, `fromdate`,
|
||||
`todate` (YYYY-MM-DD, matched on `businessdate`), `terminalid`, `cashiername`,
|
||||
`paymentmode`, `pageno`, `pagesize`.
|
||||
|
||||
`/sales/detail` takes `locationid` and `reference` — the terminal's order UUID,
|
||||
the invoice number, or the `posorderid`, whichever the caller happens to have.
|
||||
|
||||
**`locationid` is the authorisation boundary.** Every read is scoped to one
|
||||
outlet; omitting it is an error rather than a page through every shop's takings,
|
||||
and asking for a bill under the wrong outlet returns 404 even when the reference
|
||||
is valid.
|
||||
|
||||
Dates match `businessdate` — the day the sale was rung, not the day it reached
|
||||
us. A till that was offline overnight uploads yesterday's bills this morning and
|
||||
they belong to yesterday.
|
||||
|
||||
These answer with a **bare ack**, not the usual `{code, message, status}`
|
||||
envelope — the terminal reads `accepted` from the top level of the body:
|
||||
|
||||
```json
|
||||
{ "batch_id": "9f1c…", "accepted": ["order-uuid"], "rejected": {} }
|
||||
```
|
||||
|
||||
Status codes carry the rest of the contract:
|
||||
|
||||
- **200** — batch processed. Individual bills may still be refused; the ack says which.
|
||||
- **4xx** — the request is wrong (unknown outlet, bad store id). The till halts and shows a person.
|
||||
- **5xx** — outcome unknown. The till keeps everything and retries with backoff.
|
||||
|
||||
### MQTT
|
||||
|
||||
The broker is **Eclipse Mosquitto 2.1.2** at `66.116.225.226:1883`, shared with
|
||||
the rider app (`nearle/riders/#`) and the doormile project (`doormile/riders/#`).
|
||||
|
||||
There is **no NATS** in this deployment. NATS servers exist for other projects
|
||||
on other hosts, but their ports are closed from here and their configs carry no
|
||||
`mqtt {}` block, so they expose no MQTT gateway. The NATS consumer that briefly
|
||||
lived in this package has been deleted rather than left to rot.
|
||||
|
||||
| Env | Purpose |
|
||||
|---|---|
|
||||
| `MQTT_URL` | `tcp://66.116.225.226:1883`. **Unset disables MQTT ingest** — HTTP still works |
|
||||
| `MQTT_USER` / `MQTT_PASSWORD` | Broker credentials |
|
||||
| `MQTT_CLIENT_ID` | Defaults to `nearle-pos-ingest`. **Must be unique per replica** — a second connection with the same id evicts the first, and the two would fight in a loop |
|
||||
|
||||
| Topic | Direction |
|
||||
|---|---|
|
||||
| `nearle/pos/+/+/order` | till → us |
|
||||
| `nearle/pos/+/+/customer` | till → us |
|
||||
| `nearle/pos/+/+/health` | till → us, every 30s |
|
||||
| `nearle/pos/{loc}/{terminal}/ack` | us → till |
|
||||
| `nearle/pos/{loc}/catalogue` | us → every till at a shop |
|
||||
|
||||
Subscribed with `CleanSession(false)` and a stable client id, so a brief restart
|
||||
resumes rather than missing what arrived meanwhile. Re-subscribes on every
|
||||
reconnect, because a broker that did not persist the session would otherwise
|
||||
come back subscribed to nothing.
|
||||
|
||||
**Do not treat the broker as durable storage.** Two measured facts make that
|
||||
unsafe:
|
||||
|
||||
- `max_queued_messages` is at its default of **1000**. If this backend is down
|
||||
long enough for a hundred tills to exceed that, Mosquitto silently drops the
|
||||
overflow.
|
||||
- Mosquitto's `autosave_interval` defaults to **30 minutes**, so a hard kill can
|
||||
lose up to half an hour of persisted state.
|
||||
|
||||
Neither loses a bill, and that is the whole point of the acknowledgement design:
|
||||
a dropped message is simply never acked, so the terminal keeps its copy and
|
||||
sends it again. The broker is a transport, not a ledger.
|
||||
|
||||
**The store and terminal are read from the topic, never from the body.** A till
|
||||
that could name its own store in the payload could redirect another counter's
|
||||
acknowledgements.
|
||||
|
||||
## Configuring a terminal
|
||||
|
||||
Settings → Connectivity & sync → Configure.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Store ID | **The numeric `locationid`.** Not a name — the tenant is resolved from it |
|
||||
| Terminal name | Whatever staff call the till |
|
||||
| Transport | `HTTP` or `MQTT` |
|
||||
| Base URL (HTTP) | `https://your-host/live/api/v1/pos` |
|
||||
| Broker host / port (MQTT) | `66.116.225.226`, port `1883`, **TLS off** (8883 is not configured) |
|
||||
|
||||
`store_id` carrying the locationid is load-bearing: `resolvePosStore` looks the
|
||||
tenant up from it and refuses a location that is not registered. A terminal
|
||||
cannot name its tenant.
|
||||
|
||||
## Mapping decisions
|
||||
|
||||
Worth knowing before the first bill lands.
|
||||
|
||||
- **Idempotency** is a unique index on `pos_orders.terminalorderid` — the UUID
|
||||
minted at the till — plus a Postgres advisory lock held for the life of the
|
||||
transaction, so a redelivery arriving concurrently waits and then sees the
|
||||
committed row rather than racing past the check.
|
||||
- **`businessdate` is the day the sale was rung**, not the day it arrived. A
|
||||
till that was offline overnight uploads yesterday's bills this morning, and
|
||||
they belong to yesterday. Every daily figure keys on this.
|
||||
- **Line amounts are scaled onto the bill total.** The till sends each line at
|
||||
its pre-apportionment value while the header carries the total after
|
||||
bill-level discounts. Left alone the item rows would sum to the subtotal and
|
||||
every report that adds up lines would disagree with the one reading the
|
||||
header.
|
||||
- **Payment mode** is the largest tender on a split bill; the full split is
|
||||
kept verbatim in `paymentsjson` for drawer reconciliation.
|
||||
- **Fractional quantities round *up* for stock.** `productstocks.quantity` is an
|
||||
integer column, so 1.5 kg of onions cannot be recorded exactly. Rounding up
|
||||
never under-deducts, so recorded stock is never higher than the shelf. The app
|
||||
order path truncates instead (1.5 → 1), which under-deducts; that behaviour was
|
||||
left untouched rather than silently changed for live traffic. **Making the
|
||||
column numeric is the real fix.**
|
||||
- **Customers** match on `contactno` within the outlet's `applocationid`, so a
|
||||
shopper registered at a till and one who installed the app become one row.
|
||||
Registrations are **insert-if-absent** — never an update, so a profile
|
||||
corrected at head office is not reverted by a terminal replaying an old
|
||||
capture.
|
||||
- **Catalogue** answers a snapshot or a change set, decided by the `since`
|
||||
revision — see below.
|
||||
- **Barcodes** come from `products.productsku` — there is no barcode column.
|
||||
Scanning at the till matches on it, so SKUs must be the scannable code for
|
||||
barcode scanning to work.
|
||||
|
||||
## Catalogue: snapshots and deltas
|
||||
|
||||
`GET /catalogue?store_id=1135` with no `since` returns a **full snapshot**. The
|
||||
response carries a `revision`; the terminal stores it and sends it back next
|
||||
time as `since=`, and then gets only what changed.
|
||||
|
||||
A product is included in a change set when any of three things moved: the
|
||||
product row (name, tax, brand), its row at this outlet (price, availability), or
|
||||
its stock ledger. Stock counts because a shop's figure drifts from a till's on
|
||||
every sale rung at another counter, and a delta that ignored it would let that
|
||||
drift persist until someone forced a full pull.
|
||||
|
||||
**The one rule that matters.** A response marked `is_delta: false` is treated as
|
||||
a snapshot, and the terminal **withdraws every product it does not mention**. A
|
||||
filtered result labelled `false` therefore empties the shop's shelf. The filter
|
||||
and the flag are computed from a single value in `Catalogue()` — there is no
|
||||
path that filters without also setting the flag, and that is deliberate.
|
||||
|
||||
**A revision that cannot be read falls back to a full snapshot.** Malformed,
|
||||
empty, or issued to a different outlet — all yield a zero cutoff and a complete
|
||||
response. The other direction would leave a terminal permanently missing every
|
||||
change it had not already seen, with nothing to indicate it.
|
||||
|
||||
**The revision only advances on the final page.** A terminal that abandons a
|
||||
paginated pull half way gets back the revision it already had — or an empty one,
|
||||
meaning the next pull is a snapshot. Both are recoverable; a prematurely
|
||||
advanced revision is not.
|
||||
|
||||
**A delta cannot withdraw a deleted product.** A row removed from
|
||||
`productlocations` leaves no tombstone, so nothing tells the change set to
|
||||
retire it. Only a snapshot collects those, which is why a terminal should pull
|
||||
without a revision periodically — the morning import is the natural moment.
|
||||
|
||||
## Terminal health
|
||||
|
||||
Every till publishes a heartbeat to `nearle/pos/{loc}/{terminal}/health` every
|
||||
**30 seconds**. It is stored in Redis, never in Postgres.
|
||||
|
||||
| Env | Purpose |
|
||||
|---|---|
|
||||
| `REDIS_HOST` / `REDIS_PORT` | **Unset disables presence.** Point at the same Redis the express backend uses |
|
||||
| `REDIS_USER` / `REDIS_PASSWORD` / `REDIS_DB` | Defaults `default`, empty, `0` |
|
||||
|
||||
```
|
||||
pos:terminal:{terminalcode} HASH, TTL 90s
|
||||
pos:location:{locationid}:terminals SET, no TTL
|
||||
```
|
||||
|
||||
The TTL is the whole design. A heartbeat is a fact with an expiry date: a till
|
||||
that loses power stops refreshing, the key expires, and it disappears from the
|
||||
board with nothing having to notice. In Postgres this would need ~288,000 writes
|
||||
a day across a hundred tills *and* a reaper job, because a row saying "online"
|
||||
cannot age out by itself.
|
||||
|
||||
90 seconds is three missed beats. Two would make an ordinary GPRS hiccup look
|
||||
like a dead till; five would take two and a half minutes to notice a real one.
|
||||
|
||||
The set has **no TTL**, mirroring `city:{tenantid}:active_deliveries` in the
|
||||
express backend: it is an index of what exists, not a claim that any of it is
|
||||
alive. Membership means "this till has been seen here"; liveness is whether the
|
||||
hash still exists.
|
||||
|
||||
Keys are namespaced `pos:*` and do not collide with express's `delivery:*`,
|
||||
`city:*` or `rider_*`. **Worth keeping that way** — a shared datastore only stays
|
||||
safe while each writer's keys are obviously its own.
|
||||
|
||||
A heartbeat is **never acknowledged**. Presence is fire-and-forget: a till that
|
||||
stopped selling because a dashboard was busy would be a self-inflicted outage.
|
||||
|
||||
Read it back:
|
||||
|
||||
| Method | Path |
|
||||
|---|---|
|
||||
| `GET` | `/live/api/v1/pos/health/terminal?terminal_id=T4A9` |
|
||||
| `GET` | `/live/api/v1/pos/health/location?location_id=12` |
|
||||
|
||||
A till whose key has expired comes back marked `offline` rather than being
|
||||
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.
|
||||
|
||||
What a heartbeat carries: identity and app version; **queue depth**
|
||||
(`pending_bills`, `pending_registrations`, `oldest_pending_at`) — the numbers
|
||||
that make a silent sync failure visible; **today's trading** (`today_bills`,
|
||||
`today_amount`, `last_bill_at`) — a till that is connected but has rung nothing
|
||||
in three hours is usually a jammed printer or an absent cashier; and device
|
||||
state.
|
||||
|
||||
## Not built
|
||||
|
||||
- **Loyalty coming back down.** The uplink deliberately carries no points or
|
||||
spend — those belong to the bill stream, which is idempotent and sees every
|
||||
counter. Nothing yet computes them centrally and sends them to the tills, so
|
||||
a shopper's balance at a till is that till's view.
|
||||
- **Device authentication.** A terminal is trusted with a locationid. Signed
|
||||
device tokens are the obvious next step before this is exposed publicly.
|
||||
- **Battery and free storage in the heartbeat.** The reporter has a hook for
|
||||
them, but this build collects neither — they need platform packages a desktop
|
||||
build has no use for. Fields that are not collected are **omitted**, not sent
|
||||
as zero: a board showing every till at 0% battery is worse than one showing
|
||||
nothing.
|
||||
|
||||
## Broker accounts
|
||||
|
||||
Applied 2026-08-03 on `66.116.225.226`. Two scoped accounts now exist alongside
|
||||
`admin`, with an ACL at `/mosquitto/config/acl` referenced from
|
||||
`mosquitto.conf`.
|
||||
|
||||
| User | May publish | May subscribe |
|
||||
|---|---|---|
|
||||
| `pos_terminal` | `nearle/pos/+/+/{order,customer,status,health}` | `nearle/pos/+/+/{ack,command}`, `nearle/pos/+/catalogue` |
|
||||
| `pos_ingest` | `nearle/pos/+/+/{ack,command}`, `nearle/pos/+/catalogue` | `nearle/pos/+/+/{order,customer,health,status}` |
|
||||
| `admin` | everything — **deliberately unchanged** | everything |
|
||||
|
||||
A till therefore cannot publish to `nearle/riders/#` or `doormile/#`, and cannot
|
||||
write its own ack topic — only the ingest may do that. Verified by publishing as
|
||||
`pos_terminal` to all four and watching which arrived: the order did, the other
|
||||
three did not.
|
||||
|
||||
**`admin` was left unrestricted on purpose.** Its credentials are compiled into
|
||||
the rider app, so narrowing it here would cut off the live rider fleet without
|
||||
warning. The right next step is:
|
||||
|
||||
```conf
|
||||
user admin
|
||||
topic readwrite nearle/riders/#
|
||||
topic readwrite doormile/#
|
||||
```
|
||||
|
||||
but only once someone has confirmed nothing else authenticates as `admin`.
|
||||
Until then the ACL changes nothing for it — which is why applying it was safe.
|
||||
|
||||
Rollback, if ever needed:
|
||||
|
||||
```bash
|
||||
cp /root/Mqtt/backup-<timestamp>/{mosquitto.conf,passwd} /root/Mqtt/config/
|
||||
docker restart mqtt_broker
|
||||
```
|
||||
|
||||
**Still outstanding on the broker:**
|
||||
|
||||
- **No TLS.** Port 8883 is not configured. Bills carry customer names and mobile
|
||||
numbers, and they travel in the clear. Traefik on the same host already
|
||||
terminates 443, so certificates exist to borrow from.
|
||||
- **`passwd` is world-readable.** Mosquitto warns about it and future versions
|
||||
will refuse to load it. Tightening it means `chown 1883:1883` as well as
|
||||
`chmod`, because the broker runs as uid 1883 and a root-owned 0600 file would
|
||||
stop it starting.
|
||||
- **Credentials in source.** `admin` is in the rider APK, Redis is hardcoded in
|
||||
the express backend, and Postgres was in this repository's git history until
|
||||
2026-08-03. The POS accounts above are the only ones not in any source tree —
|
||||
keep it that way.
|
||||
|
||||
## Capacity
|
||||
|
||||
Current load, measured: **~0.9 msg/s inbound**, 5 connected clients, 112
|
||||
retained messages totalling 8 KB.
|
||||
|
||||
A hundred tills add roughly 3.3 msg/s steady (a 1 KB heartbeat each per 30s)
|
||||
plus bursts of up to ~50 KB when a sale batch goes up. That is 3–4× current
|
||||
traffic and well within what Mosquitto handles on any VPS. The broker will not
|
||||
be the bottleneck; Postgres write throughput on bill ingest is the thing to
|
||||
watch instead.
|
||||
95
docs/SECURITY_HANDOFF.md
Normal file
95
docs/SECURITY_HANDOFF.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Handoff: Broken Access Control (IDOR) audit & fixes — Fiesta backend
|
||||
|
||||
Repo: `backend_fiesta` (Go + Fiber + GORM), consumed by `nearledaily/daily_merchant_web` (React/TS) and a mobile app (not in this repo).
|
||||
|
||||
## 1. The root problem (still not fully fixed — read this first)
|
||||
|
||||
**There is no authentication system in this backend.** Grep confirms:
|
||||
- No JWT/session token is ever issued. `Login`, `TenantLogin`, `TenantWebLogin`, `AppLogin` (in `controllers/userController.go`) just look up the user/tenant and return their info in the JSON body — no token.
|
||||
- No auth middleware exists anywhere. `routes/routes.go` / `main.go` only wire up CORS middleware. Every route is wide open — anyone who can reach the API can call any endpoint with any query params.
|
||||
|
||||
Because of that, every endpoint trusts client-supplied query params (`tenantid`, `customerid`, `partnerid`, etc.) as the sole source of "who is asking." There is currently **nothing stopping a logged-in store admin for tenant 1135 from just requesting `?tenantid=1136`** and getting another tenant's data — the frontend happens to always send the logged-in user's own tenantid, but the backend never checks it.
|
||||
|
||||
**This session's fixes only close one specific hole**, described below. The real fix — deriving identity server-side from a verified token instead of trusting query params — has not been started. Whoever picks this up should treat that as the actual next milestone.
|
||||
|
||||
## 2. The specific bug that was found and fixed this session
|
||||
|
||||
Pattern found repeatedly across the codebase: repository functions build SQL dynamically, e.g.
|
||||
|
||||
```go
|
||||
query := "SELECT ... FROM orders WHERE 1=1"
|
||||
if tenantID != 0 {
|
||||
query += " AND tenantid = ?"
|
||||
params = append(params, tenantID)
|
||||
}
|
||||
// ...similar optional blocks for partnerid, customerid, etc.
|
||||
```
|
||||
|
||||
**If none of the scoping params were supplied (0 / empty), the query silently fell through to "no WHERE clause" and returned every row in the table across every tenant.** This was directly reachable — e.g. `orders/getorders` with no `tenantid` returned all ~300 orders in the DB rather than 400ing, which is how the user first noticed this (logged in as a store admin, expected only their store's orders, saw everyone's).
|
||||
|
||||
### Fix pattern applied
|
||||
|
||||
Rather than rewriting every repository query (large surface area, higher regression risk), a **controller-level guard** was added to each affected endpoint: if none of the valid scoping ids are present in the query string, return `400` immediately instead of calling the service/repo at all.
|
||||
|
||||
Standard error shape used everywhere:
|
||||
```json
|
||||
{ "status": false, "code": 400, "message": "<specific message>" }
|
||||
```
|
||||
|
||||
## 3. Endpoints fixed (8 total)
|
||||
|
||||
| # | Endpoint | File / function | Guard added |
|
||||
|---|---|---|---|
|
||||
| 1 | `GET /v1/web/orders/getorders` (+ mob) | `controllers/orderController.go` `GetOrders` (line 24) | requires one of `tenantid`, `partnerid`, `customerid`, `applocationid`, `appuserid` — else 400 (line ~102-110). Previously the `else` branch called `GetAllOrders` (unscoped). |
|
||||
| 2 | `GET /v1/web/orders/getordersummary` | `controllers/orderController.go` `GetOrderSummary` (line 129) | requires one of `tenantid`, `partnerid`, `customerid`, `locationid` (line 137-143) |
|
||||
| 3 | `GET /v1/web/orders/getlocationsummary` | `controllers/orderController.go` `GetlocationOrderSummary` (line 163) | requires `tenantid` (line 167-173) |
|
||||
| 4 | `GET /v1/web/users/getallusers` | `controllers/userController.go` `GetAllUsers` (line 22) | requires `tenantid` (line 29-35). Note: this endpoint's query selects `a.pin` (login PIN) — this was a high-severity leak (PINs across all tenants) before the fix. |
|
||||
| 5 | `GET /v1/web/deliveries/getdeliveries` (+ mob) | `controllers/deliveriesController.go` `GetDeliveries` (line 194) | requires one of `tenantid`, `partnerid`, `customerid`, `applocationid`, `userid`, `appuserid` (line 212-218) |
|
||||
| 6 | `GET /v1/web/partners/getriders` (+ mob) | `controllers/partnerController.go` `GetActiveRiders` (line 19) | requires one of `tenantid`, `partnerid`, `applocationid`, `userid` (line 25-31). Lower severity — underlying repo query defaults to `userid = 0` rather than a full dump, but fixed for consistency. |
|
||||
| 7 | `GET /v1/web/partners/getriderlogs` (+ mob) | `controllers/partnerController.go` `GetRiderLogs` (line 121) | requires one of `partnerid`, `applocationid` (line 127-133). **Also fixed an unrelated bug in the same function**: `tdate` was reading `c.Query("fromdate")` (copy-paste error) so the end of any date range was always silently overwritten with the start date. Now correctly reads `c.Query("todate")` (line 125). |
|
||||
| 8 | `POST /v1/mob/orders/getcustomerorders` | `controllers/orderController.go` `GetCustomerOrders` (line 374) | requires `customerid` (line 394-400) |
|
||||
|
||||
### Also fixed alongside #2: SQL injection in `GetOrderSummary`
|
||||
|
||||
`repositories/orderRepository.go` `GetOrderSummary` previously built the date filter by **string-concatenating** `fdate`/`tdate` directly into raw SQL. Rewritten to use parameterized `?` placeholders passed through `r.db.Raw(query, params...)`. The `strconv` import was removed from that file since it became unused after the rewrite (verified via grep no other usage remained).
|
||||
|
||||
## 4. Reviewed and explicitly NOT changed (don't re-flag these)
|
||||
|
||||
Same `WHERE 1=1` pattern exists elsewhere but was judged not to be a bug, or already safe:
|
||||
|
||||
- **`repositories/tenantRepository.go` `GetAllTenants`** — intentionally lists all tenants for a platform/super-admin console. The gap here is "no RBAC to restrict who can call this," which is the same root-cause auth gap from section 1, not a scoping bug to patch individually.
|
||||
- **`repositories/productRepository.go` `GetProductSubCategory`** — has an explanatory comment: subcategories are intentionally shared/global master data plus tenant-owned overrides. Not a bug.
|
||||
- **`repositories/productRepository.go` `GetProductCount`** — returns aggregate counts only (no PII), low severity, left as-is.
|
||||
- **`repositories/utilsRepository.go` `GetSubcategories`** — global taxonomy/reference data; the model has no tenant field at all.
|
||||
- **`repositories/orderRepository.go` `GetAdminOrders`** — has `WHERE 1=1` internally but is safe because its only caller (`GetOrders` controller) only invokes it when `applocationid != 0`.
|
||||
- **`repositories/orderRepository.go` `GetAllOrders`** — now dead code (unreachable) after fix #1 above; confirmed via grep it's no longer called anywhere. Could be deleted as cleanup but left in place.
|
||||
- **`repositories/tenantRepository.go` `GetTenantLocations`** — already always filters `WHERE tenantid = ?`. Safe, unchanged.
|
||||
|
||||
## 5. Known pre-existing bug found during this audit, NOT yet fixed anywhere
|
||||
|
||||
**Frontend/backend path mismatch on rider logs.** In `nearledaily/daily_merchant_web/src/services/fiestaApi.ts`, `getRiderLogs()` (~line 1132) calls:
|
||||
```ts
|
||||
fiestaGet('riders/getriderlogs', {...})
|
||||
```
|
||||
`FIESTA_BASE` is `https://fiesta.nearle.app/live/api/v1/web`, so this resolves to `.../v1/web/riders/getriderlogs`. But the backend only registers this route under the `partners` group (`routes/partnerroutes.go`): `partner.Get("/getriderlogs", ...)` on `api.Group("/v1/web/partners")`, i.e. the real path is `.../v1/web/partners/getriderlogs`. Confirmed via grep there is no `/v1/web/riders` route group anywhere in the backend.
|
||||
|
||||
**This means `getRiderLogs()` in the web console has likely been 404ing already, independent of anything fixed this session.** Fix is a one-line FE change: `'riders/getriderlogs'` → `'partners/getriderlogs'`. Not fixed yet because it's a frontend-repo change and wasn't the scope of this backend security pass — flagging it here so it isn't lost.
|
||||
|
||||
## 6. Frontend compatibility check (already done, no FE changes needed for the 8 fixes above)
|
||||
|
||||
Checked `daily_merchant_web/src/services/fiestaApi.ts` against every fix — the web console already sends the now-required params in all cases:
|
||||
- `getOrders`, `getAllUsers`, `getDeliveries`, `getOrderSummary`, `getLocationSummary` — all declare `tenantid: number` as a **required** (non-optional) TS field already.
|
||||
- `getRiders` — always sends `applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID` (never zero/undefined) plus required `tenantid`.
|
||||
- `getRiderLogs` — sends `tenantid`/`applocationid` when available, but see the path bug in section 5 — worth re-verifying once that's fixed.
|
||||
- **`mob/orders/getcustomerorders`** (fix #8) is called from the **mobile app**, which is not in this repo — whoever owns that codebase needs to verify every call site always sends `customerid`. Not verified in this session.
|
||||
|
||||
## 7. Environment note
|
||||
|
||||
**No Go toolchain is available in the sandbox this session ran in** (`command not found: go`). All edits above were manually reviewed (imports, syntax, call sites checked via Read/grep) but **never compiled**. Run `go build ./...` and the existing test suite (if any) before deploying any of this.
|
||||
|
||||
## 8. Suggested next steps for whoever picks this up
|
||||
|
||||
1. `go build ./...` and smoke-test all 8 changed endpoints (call with and without the required param, confirm 200 vs 400).
|
||||
2. Fix the `riders/getriderlogs` → `partners/getriderlogs` path bug in `fiestaApi.ts` (section 5).
|
||||
3. Verify the mobile app always sends `customerid` to `mob/orders/getcustomerorders` before this ships, since that's the one fixed endpoint not verified from a frontend contract.
|
||||
4. Scope and plan the real fix: JWT/session auth issuance + middleware, so `tenantid`/`customerid`/etc. are derived from a verified server-side identity instead of trusted from query params. Until that lands, the 8 fixes in this doc only prevent the "forgot to pass an id → get everything" failure mode — they do **not** prevent a malicious or buggy client from passing a *different* tenant's/customer's/partner's real id and getting their data.
|
||||
Reference in New Issue
Block a user