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