pos login with the ph number and pin

This commit is contained in:
2026-08-12 17:10:27 +05:30
parent ff72af9a8a
commit d566ca5591
9 changed files with 1007 additions and 91 deletions

View File

@@ -29,10 +29,10 @@ told.**
```bash
BASE=https://fiesta.nearle.app/live/api/v1/pos
# 1. Sign in
# 1. Sign in — a mobile number and a 4-digit PIN
curl -s -X POST $BASE/login \
-H 'Content-Type: application/json' \
-d '{"authname":"rsselvapuram@gmail.com","password":"…","terminal_id":"T5EDD"}'
-d '{"contactno":"9876543210","pin":"4821","terminal_id":"T5EDD"}'
# 2. Use the token on everything else
curl -s $BASE/session -H "Authorization: Bearer $TOKEN"
@@ -44,9 +44,9 @@ curl -s $BASE/session -H "Authorization: Bearer $TOKEN"
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.
**1. Sign in.** `POST /login` with the operator's **mobile number and 4-digit
PIN** — the pair the back office issued them. Both are held on their own
`app_users` row; there is no separate POS credential store.
**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.
@@ -74,8 +74,8 @@ The only unauthenticated route. It is where a token comes from.
```json
{
"authname": "rsselvapuram@gmail.com",
"password": "…",
"contactno": "9876543210",
"pin": "4821",
"terminal_id": "T5EDD",
"device_id": "a5f3…",
"location_id": 1135,
@@ -85,15 +85,15 @@ The only unauthenticated route. It is where a token comes from.
| Field | Required | Notes |
|---|---|---|
| `authname` | yes* | Email. **Or** send `contactno` instead. |
| `contactno` | yes* | Mobile number, as an alternative to `authname`. |
| `password` | yes | |
| `contactno` | yes | Mobile number. Send it as typed — `+91 98765 43210`, `098765-43210` and `9876543210` all reach the same account. |
| `pin` | yes | Exactly 4 digits, never starting with `0`. |
| `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. |
| `authname` + `password` | no | The previous way in. Still accepted, so a shop whose numbers have not been backfilled is not stranded — see [POS_PHONE_PIN_LOGIN_HANDOVER.md](POS_PHONE_PIN_LOGIN_HANDOVER.md). |
\* one of `authname` or `contactno`.
`pin` wins if a password is sent as well; `authname` wins over `contactno`.
### Response — `200`
@@ -128,7 +128,7 @@ The only unauthenticated route. It is where a token comes from.
"staff": [
{ "user_id": 1148, "full_name": "Ragul Kannan",
"role": "Super admin", "pin": "1111", "status": "Active" }
"role": "Super admin", "status": "Active" }
]
}
}
@@ -156,7 +156,10 @@ 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.**
**`staff`** — see [Staff and PINs](#staff-and-pins). **Often empty**, and it
**no longer carries `pin`**: a PIN is now half of the sign-in, so a list of them
is a list of working credentials for the outlet. Switch operator through
`POST /login/pin` instead.
---
@@ -191,9 +194,13 @@ Requires the token. Returns `401` when there isn't one.
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.
**Takes no parameters.** 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.
Names and roles only; **`pin` is not returned here either**, for the same reason
it left the login session. A supervisor who needs to see or change one uses
`GET /pos/users`, which is role-gated.
```json
{
@@ -258,14 +265,18 @@ login, it is simply not found.
one, 22 live accounts have it including a delivery rider, and it grants nothing
on either side.
### Every till account gets its own username and password
### Every till account gets its own number and PIN
Both roles. A PIN cannot open a *closed* terminal — `/pos/login/pin` requires a
session that already exists — so a PIN-only account works only while somebody
else is standing there to unlock the till first. For a Supervisor that was an
outright deadlock; for a Cashier it meant a shop that could not open until two
people had arrived, and whoever gets in at seven is as often the cashier as the
supervisor.
Both roles. A PIN now opens a *closed* terminal too, paired with the account's
mobile number — which is what removed the old deadlock: `/pos/login/pin` needs a
session that already exists, so before this a PIN-only account could not unlock
a till at all. For a Supervisor that was an outright deadlock; for a Cashier it
meant a shop that could not open until two people had arrived, and whoever gets
in at seven is as often the cashier as the supervisor.
**So a till account needs both `contactno` and `pin` set.** One without the
other cannot sign in. A username and password still work, and every account
created before this still has them.
So a Cashier signs in exactly like a Supervisor does, and the *role* decides
what they get — not which credential they used:
@@ -293,9 +304,10 @@ outlet — becomes `cashier2.1185@pos.nearle.in`; a name **you** supplied is nev
adjusted, it is refused, because silently signing somebody in as another
person's address is worse than an error.
The PIN stays optional. It switches operator at an open counter, which not every
shop does, and it is the one credential the till holds in plaintext to hand
around — so it is set deliberately, never by default.
**The PIN is no longer optional in practice.** The field still is — creation
accepts an account without one — but an account with no PIN cannot sign a
terminal in, and is told so by name: *"this account has no PIN set; ask your
supervisor to set one in the web console first."*
---
@@ -303,11 +315,12 @@ around — so it is set deliberately, never by default.
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.
**Requires an existing valid token.** A PIN alone is four digits — ten thousand
guesses, and no barrier to an anonymous caller. Tying it to a session confines
the guesses to one outlet's staff, at a terminal somebody has already opened.
That is why this route takes a bare PIN and `/login` does not: there, the PIN is
checked against one mobile number, and the number is what makes the pair worth
anything.
```bash
curl -s -X POST $BASE/login/pin \
@@ -486,23 +499,29 @@ GET /catalogue?store_id=1185 → 403 {"message":"this session cannot reach o
| 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 |
| `400` | Body unreadable, or `a mobile number is required` / `a PIN is required` — one of the two fields is empty | Fix the request; do not send it again unchanged |
| `401` | `those sign-in details were not recognised` | Ask them to re-type. **A wrong number and a wrong PIN give the same message** — deliberately, so the endpoint isn't a directory of who banks here. A number that cannot be ten digits, and a PIN that is not four, answer the same way |
| `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 PIN set; ask your supervisor to set one in the web console first`
- `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.
That last one is real, not theoretical: neither `authname` nor `contactno` is
unique in this schema. Live data has the same address twice, and 34 mobile
numbers shared by 104 accounts. We refuse rather than pick one, because picking
wrong means billing into another tenant's books.
Sign-in only ever looks at **till accounts** (roleid 7 and 8), which is what
makes a number workable as a credential: it has to be unique among a tenant's
own till staff, not across all 608 users on the platform.
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

View File

@@ -1,5 +1,17 @@
# POS sign-in by mobile number, and shift assignment — handover to the terminal team
> ## ⚠️ Superseded by [POS_PHONE_PIN_LOGIN_HANDOVER.md](POS_PHONE_PIN_LOGIN_HANDOVER.md)
>
> `POST /pos/login` now takes a mobile number and a **PIN**, not a mobile number
> and a password, and the session no longer carries `staff[].pin`. Read the new
> handover instead — build against this one and the sign-in screen will be
> wrong.
>
> Still accurate here, and not repeated there: the `createposuser` /
> `updateposuser` / `getposusers` field changes (§2), the number format rules
> (§3), and shifts (§4). **Not** accurate here: §2's claim that `shift_*`
> appears on `staff[]` in the `/pos/login` session — it never did.
Backend is done and builds clean. **Nothing about this breaks the current app** —
username sign-in keeps working exactly as it does today. Read §5 before you ship
anything, because the switch has one ordering rule that will lock out every

View File

@@ -0,0 +1,311 @@
# POS sign-in by mobile number and PIN — handover to the terminal team
`POST /pos/login` now takes a **mobile number and a four-digit PIN**. Backend is
done, builds clean, and is proved end to end against a real database (§8).
Two things need reading before you ship: **§5**, because shipping the new screen
before the back office has filled in numbers and PINs locks out every cashier in
every shop; and **§4**, because the login response no longer carries staff PINs
and any terminal switching operator from that array will stop working.
Supersedes `POS_PHONE_LOGIN_HANDOVER.md`, which described the same endpoint
taking a number and a *password*.
---
## 1. What changed, in one line
A till signs in with the two things a person standing at a counter can actually
type — their number and their PIN — and the response stops handing out everyone
else's PIN.
---
## 2. The endpoint
`POST https://fiesta.nearle.app/live/api/v1/pos/login`
Unauthenticated. It is where a token comes from. Everything else on the POS
group sits behind the session it issues.
### Payload
```jsonc
{
"contactno": "9876543210", // required
"pin": "4821", // required — exactly 4 digits, never starts with 0
"terminal_id": "T5EDD", // optional, recorded on the session
"device_id": "a5f3…", // optional
"location_id": 1135, // optional, multi-outlet accounts only
"configid": 1 // optional, only if you are told to send it
}
```
| Field | Required | Notes |
|---|---|---|
| `contactno` | **yes** | Send it as typed. `+91 98765 43210`, `098765-43210` and `9876543210` all reach the same account — the server reduces every number to ten digits before matching. |
| `pin` | **yes** | Exactly 4 digits. `0451` is not a PIN here and never was — see §3. |
| `terminal_id` | no | This till's short code. Recorded on the session so a stolen token can be told apart from the terminal it was issued to. |
| `device_id` | no | The device's stable UUID. |
| `location_id` | no | Means something **only** for an account entitled to more than one outlet. It is a request, not an assertion: checked against what the account may reach, and refused if it is not one of them. |
| `configid` | no | Inferred when absent. Send it only after the ambiguity error in §6. |
| `authname` + `password` | no | The previous way in. **Still works** — see §5. |
If both are sent, `pin` is used over `password` and `authname` over `contactno`.
### Response — `200`, unchanged in shape
```jsonc
{
"code": 200,
"status": true,
"message": "Login successful",
"details": {
"token": "eyJ1aWQiOjQwMDEsInRpZCI6MTA4Nywi….PKmoMn92BMs",
"expires_at": "2026-09-11T11:31:08Z",
"user_id": 4001,
"full_name": "Meena Sundaram",
"email": "meena@example.com",
"role_id": 7,
"role": "Supervisor",
"can_manage_staff": true,
"tenant_id": 1087,
"tenant_name": "R Mart",
"store_id": "1135",
"location_id": 1135,
"location_name": "Selvapuram",
"gstin": "33AABCU9603R1ZM",
"address": "4 Trichy Road",
"phone": "04422334455",
"locations": [
{ "location_id": 1135, "location_name": "Selvapuram",
"address": "4 Trichy Road", "city": "Coimbatore", "status": "Active" }
],
"staff": [
{ "user_id": 4002, "full_name": "Priya Raman",
"role": "Cashier", "status": "Active" }
]
}
}
```
**The only change to the response is that `staff[].pin` is gone.** Everything
else — `token`, `expires_at`, `store_id`, `locations[]`, `can_manage_staff` —
is byte-for-byte what it was. §4 is why, and what to do instead.
Two corrections to what the previous handover promised about this response:
`staff[]` has **no** `shift_id` / `shift_name` / `shift_start` / `shift_end`
fields, and never did. The shift fields exist on `GET /web/tenants/getposusers`.
If the terminal needs the shift on the sign-in screen, say so and it can be
added — do not write code against it today.
---
## 3. What counts as a PIN, and the trap in it
Four digits, and **never a leading zero**. `app_users.pin` is a `bigint`, so
`0451` is stored as `451` and read back as three digits — somebody would type
four and be refused for ever. Creation refuses those, so this only matters for
what you let a person type: accept four digits, send them as a string.
**Do not add a client-side guessable-PIN check.** The console refuses to *issue*
`1234`, `1111`, `9999` and a handful more, but live data already holds `1234` on
eleven accounts and `1111` on nine — issued before that rule existed. Sign-in
deliberately accepts them, because refusing them would lock twenty real people
out of terminals this platform signed them up to. A rule about what may be
created is not a rule about what may be typed.
**Number format.** Non-digits are stripped, then a leading `91` or `0` is
dropped once. Anything that is not ten digits afterwards is rejected. Do not
add a country code the user did not type, and do not reject `+91` locally — let
it through and let the server reduce it.
---
## 4. 🔴 `staff[].pin` is gone — read this if you switch operators offline
The login session used to carry every colleague's PIN so the till could switch
operator without a round trip. That was defensible while a PIN was *shift
attribution*: the token decided which books a terminal could reach, and the PIN
only decided whose name went on the bill.
That stopped being true the moment the PIN became half of the sign-in. The array
would now be a list of working credentials for the whole outlet — including the
supervisor's, which carries `can_manage_staff`. Any cashier could read it and
sign back in as their own manager.
So `pin` no longer appears in `staff[]`, and no longer appears in `GET
/pos/staff` either. `staff[]` still carries `user_id`, `full_name`, `role` and
`status`, so the operator picker still works.
**Switch operator through `POST /pos/login/pin`** — an existing session, a bare
PIN, and you get a fresh session with the new person's role:
```bash
curl -s -X POST "$B/pos/login/pin" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"pin":"7391"}'
```
**The cost, stated plainly: this needs the network.** A till that switched
operators offline against the cached array can no longer do so. If that matters
for your shops, tell us — the options are a short-lived cache of hashed PINs on
the device, or a separate endpoint a supervisor calls once to warm one. Neither
is built, and neither should be built on a guess about how shops actually work.
One wart we did **not** change: `staff[]` can contain a back-office account that
has a PIN and sits at that location — the query filters on having a PIN, not on
role. Tapping them and typing their PIN answers *"this account is not set up for
the till"*. Filtering by role would look tidier and would empty the staff list
for a great many real shops, whose people carry role ids that are not POS roles
at all. Show the message; it names the fix.
---
## 5. 🔴 Sequencing — read this before shipping
**Every till account on the platform predates the number it now signs in with.**
When the previous handover was written, all 12 live POS accounts had
`contactno = ""`. Some also have no PIN. An account needs **both** to sign in.
If the app ships number-and-PIN **only**, every cashier in every shop is locked
out on the next update.
**Required order:**
1. **Backend deploys.** Nothing changes for the app — username and password
still work, unchanged.
2. **Back office fills in a mobile number and a PIN on every existing till
account** through the console (`PUT /web/tenants/updateposuser`). New
accounts already require a number.
3. **Only then** does the app make number-and-PIN the primary sign-in.
**Recommendation for the app:** ship the new screen, and keep a small
"sign in with a username instead" link behind it until step 2 is confirmed
finished for every shop. One un-backfilled account then means one awkward login,
not a shop that cannot open.
**Verify step 2 is actually done** before you flip anything — a shop is ready
only when every one of its till accounts has both fields:
```bash
curl -s "$B/web/tenants/getposusers?tenantid=1087&locationid=1135" \
-H "Authorization: Bearer $CONSOLE_TOKEN" | jq '.details.users[] | {full_name, contactno, pin}'
```
---
## 6. Errors you should handle
| Code | Message | What it means | What the app should do |
|---|---|---|---|
| `400` | `a mobile number is required` | The field was empty, or held no digits at all | Fix locally; do not resend unchanged |
| `400` | `a PIN is required` | Neither `pin` nor `password` was sent | Fix locally |
| `401` | `those sign-in details were not recognised` | Wrong number, wrong PIN, a number that cannot be ten digits, a PIN that is not four, or somebody who has left | *"Check your number and PIN"* — do **not** say which was wrong |
| `403` | `this account has no PIN set; ask your supervisor to set one in the web console first` | Provisioned without one | Show verbatim; it names the fix |
| `403` | `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` | A back-office login was used | Show verbatim |
| `403` | `more than one account uses these sign-in details; ask your administrator for the configid and send it with the login` | Ambiguous match | Show verbatim; it needs the back office |
| `403` | `this account cannot open a till at outlet 1185` | `location_id` named an outlet this account cannot reach | Drop `location_id` and retry, then show the picker from `locations[]` |
The `401` is deliberately one message for several causes. Distinguishing them
turns an unauthenticated endpoint into a directory of who banks here.
---
## 7. 🔴 One thing this endpoint cannot fix: rate limiting
A four-digit PIN is ten thousand guesses. On `/pos/login/pin` that is contained,
because the route needs a valid session and can only reach one outlet's staff.
On `/pos/login` it is **not** — the route is unauthenticated by necessity, and
the pair is only strong while an attacker cannot sit and try every PIN against a
number they know.
Nothing in this change adds a limiter, and the endpoint is the wrong place for
one. **Before this becomes the only way into a till, `/pos/login` needs a rate
limit at the edge** — per source and per `contactno`, with a lockout after a
few failures. Please raise it with whoever owns the ingress; it is not the
terminal team's job, but shipping the screen without it is what would make it
somebody's incident.
---
## 8. How this was verified
Not against production — there are no live credentials in this working copy, so
nothing here was run against the real database. Proved instead against a
throwaway Postgres seeded with a shop and six accounts, running the **real**
repository, service and SQL:
```bash
docker run -d --rm --name nearle-posproof -e POSTGRES_PASSWORD=proof \
-e POSTGRES_DB=proof -p 55432:5432 postgres:16-alpine
POS_PROOF_DSN='postgres://postgres:proof@localhost:55432/proof?sslmode=disable' \
POS_TOKEN_SECRET=proof-secret-at-least-16 \
go run ./scratch/posphonepinproof # 18 passed, 0 failed
```
Covered: sign-in by number and PIN; the same account typed four different ways;
a cashier getting a cashier's session; a weak-but-issued PIN still admitted; a
wrong PIN, an unknown number, an account with no PIN, a back-office account, a
malformed number, a malformed PIN and a leaver all refused with the right
answer; username-and-password still working; `POST /login/pin` still switching
operator; no PIN anywhere in the response; a token minted.
Unit tests for the same rules: `go test ./repositories/` —
`repositories/posLogin_test.go`.
**Still to do against live data, by whoever has the credentials:** confirm the
regression check below, and count how many till accounts still lack a number or
a PIN (§5, step 2).
```bash
B=https://fiesta.nearle.app/live/api/v1
# regression: username sign-in must still work — run this first
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
-d '{"authname":"supervisor.1135@pos.nearle.in","password":"…"}' \
| grep -o '"can_manage_staff":[a-z]*'
# expect: "can_manage_staff":true
# the new way in, once that account has a number and a PIN
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
-d '{"contactno":"9876543210","pin":"4821"}' \
| grep -o '"can_manage_staff":[a-z]*'
# and no PIN in the response
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
-d '{"contactno":"9876543210","pin":"4821"}' | grep -c '"pin"'
# expect: 0
```
---
## 9. What did **not** change
- The response shape, apart from `staff[].pin` leaving
- `token` format, TTL (30 days) and the `PosAuth` guard
- `POST /pos/login/pin` — same request, same answer, and it still needs a session
- `POST /pos/orders`, `/pos/customers`, `/pos/health`, `GET /pos/catalogue`
- `GET /pos/users`, which still shows PINs to a supervisor and blanks them for a
cashier — a supervisor sets those PINs, so seeing them tells them nothing they
could not already change
- `POS_AUTH_REQUIRED` still defaults to off
---
## 10. Where this lives, if you need to read it
| What | Where |
|---|---|
| Which credential was offered, and how a row is checked against it | `repositories/posAuthRepository.go` — `posLoginSecret` |
| Which column names the account, and number normalisation | `repositories/posAuthRepository.go` — `posLoginIdentity` |
| PIN format at sign-in vs. the rule for issuing one | `repositories/posUserRepository.go` — `posLoginPin`, `validatePosPin` |
| The staff list, and why `Pin` is `json:"-"` | `models/pos.go` — `PosStaffMember` |
| Request/response shapes | `models/pos.go` — `PosLoginRequest`, `PosSession` |
| Full endpoint reference | `docs/POS_LOGIN.md` |