Files
backend_fiesta/docs/POS_PHONE_PIN_LOGIN_HANDOVER.md

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:

  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:

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[].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