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

@@ -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` |