14 KiB
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
{
"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
{
"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:
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:
- Backend deploys. Nothing changes for the app — username and password still work, unchanged.
- 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. - 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:
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:
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).
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[].pinleaving tokenformat, TTL (30 days) and thePosAuthguardPOST /pos/login/pin— same request, same answer, and it still needs a sessionPOST /pos/orders,/pos/customers,/pos/health,GET /pos/catalogueGET /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 changePOS_AUTH_REQUIREDstill 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 |