Add pending POS scratch checks and portfolio notes

Untracked in the working tree before today's work; committed so the
branch carries everything on disk except a stray duplicate
(docs/MOBILE_ORDER_VERIFICATION copy.md).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-15 17:04:34 +05:30
parent 72907dae74
commit 369e9fc7b4
3 changed files with 1211 additions and 0 deletions

View File

@@ -0,0 +1,267 @@
// Does the mobile-number-and-PIN sign-in actually work against the live data?
//
// Picks a supervisor and a cashier that already have both halves of the
// credential, prints them so they can be typed into a terminal, then runs the
// real repository and service — the same code path the HTTP handler calls — and
// reports what came back.
//
// Read-only. PosLogin issues SELECTs and mints a token in memory; nothing here
// writes, and the token is not persisted anywhere by design.
//
// Numbers and PINs are masked unless -show is passed. They belong to real
// people at real shops, and the default should not be to print them into
// whatever is capturing this program's output.
//
// go run ./scratch/poslivecheck # picks a ready pair, masked
// go run ./scratch/poslivecheck -show # prints the credentials
// go run ./scratch/poslivecheck -show 1087 1137 # ...at a named outlet
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"strconv"
"strings"
"nearle/models"
"nearle/repositories"
"nearle/services"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type account struct {
Userid int
Tenantid int
Locationid int
Roleid int
Fullname string
Contactno string
Pin int64
}
func main() {
_ = godotenv.Load()
if strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET")) == "" {
log.Fatal("POS_TOKEN_SECRET is not set; sign-in mints a token and will fail without it")
}
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
log.Fatal(err)
}
// Only accounts holding both halves are candidates. An account missing
// either cannot sign in at all, and picking one would prove nothing except
// that the rejection works.
where := `COALESCE(roleid,0) = ?
AND COALESCE(pin,0) BETWEEN 1000 AND 9999
AND TRIM(COALESCE(contactno,'')) <> ''
AND LOWER(COALESCE(status,'active')) <> 'inactive'`
args := []interface{}{}
// -show opts into printing the credentials themselves.
show := false
rest := []string{}
for _, arg := range os.Args[1:] {
if arg == "-show" || arg == "--show" {
show = true
continue
}
rest = append(rest, arg)
}
if len(rest) > 1 {
tenantID, _ := strconv.Atoi(rest[0])
locationID, _ := strconv.Atoi(rest[1])
where += ` AND tenantid = ? AND locationid = ?`
args = append(args, tenantID, locationID)
}
pick := func(roleID int) *account {
var a account
params := append([]interface{}{roleID}, args...)
err := db.Raw(`
SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
COALESCE(roleid,0) AS roleid,
TRIM(CONCAT(COALESCE(firstname,''),' ',COALESCE(lastname,''))) AS fullname,
COALESCE(contactno,'') AS contactno, COALESCE(pin,0) AS pin
FROM app_users WHERE `+where+` ORDER BY userid LIMIT 1`, params...).Scan(&a).Error
if err != nil || a.Userid == 0 {
return nil
}
return &a
}
supervisor := pick(models.PosRoleSupervisor)
cashier := pick(models.PosRoleCashier)
fmt.Println("Accounts that can sign in today")
fmt.Println(strings.Repeat("-", 78))
for _, pair := range []struct {
label string
a *account
}{{"supervisor", supervisor}, {"cashier", cashier}} {
a := pair.a
if a == nil {
fmt.Printf(" %-11s none — no account of this role has both a number and a PIN\n", pair.label)
continue
}
fmt.Printf(" %-11s userid %-6d tenant %-6d outlet %-6d %s\n",
pair.label, a.Userid, a.Tenantid, a.Locationid, a.Fullname)
fmt.Printf(" %-11s mobile %s PIN %s\n\n", "",
mask(a.Contactno, show), maskPin(a.Pin, show))
}
if !show {
fmt.Println(" (masked — re-run with -show to print them)")
}
if supervisor == nil && cashier == nil {
log.Fatal("nothing to test with")
}
repo := repositories.NewPosRepository(db)
svc := services.NewPosService(repo, nil)
fmt.Println("\nSigning in (real service, live data)")
fmt.Println(strings.Repeat("-", 78))
pass, fail := 0, 0
check := func(name string, ok bool, detail string) {
if ok {
pass++
fmt.Printf(" PASS %-46s %s\n", name, detail)
return
}
fail++
fmt.Printf(" FAIL %-46s %s\n", name, detail)
}
signIn := func(label string, a *account) *models.PosSession {
if a == nil {
return nil
}
session, err := svc.Login(models.PosLoginRequest{
Contactno: a.Contactno,
Pin: strconv.FormatInt(a.Pin, 10),
})
if err != nil {
check(label+" signs in", false, err.Error())
return nil
}
check(label+" signs in", true, fmt.Sprintf(
"%s at %s (outlet %d), can_manage_staff=%v",
session.Role, session.Locationname, session.Locationid, session.Canmanagestaff))
check(label+" gets a token", session.Token != "",
fmt.Sprintf("%d chars, expires %s", len(session.Token), session.Expiresat))
return session
}
supSession := signIn("supervisor", supervisor)
cashSession := signIn("cashier", cashier)
if supSession != nil {
check("supervisor can manage staff", supSession.Canmanagestaff, "role 7 grants it")
}
if cashSession != nil {
check("cashier cannot manage staff", !cashSession.Canmanagestaff, "role 8 does not")
}
// The response must not carry anyone's PIN — the whole point of the change
// that removed staff[].pin.
//
// Checked against the serialised JSON, not the Go struct. PosStaffMember.Pin
// is still *populated* — the query needs it to drop two people sharing a PIN
// — and is kept off the wire by `json:"-"`. Asserting on the struct field
// tests the wrong layer and fails a correct implementation.
if supSession != nil {
encoded, merr := json.Marshal(supSession)
check("session serialises", merr == nil, errText(merr))
if merr == nil {
check("no PIN travels in the session", !strings.Contains(string(encoded), `"pin"`),
fmt.Sprintf("%d staff listed, %d bytes of JSON", len(supSession.Staff), len(encoded)))
}
}
// A number typed the way a person actually types it.
if supervisor != nil && len(supervisor.Contactno) == 10 {
for label, typed := range map[string]string{
"+91 with spaces": "+91 " + supervisor.Contactno[:5] + " " + supervisor.Contactno[5:],
"leading zero": "0" + supervisor.Contactno,
"bare ten digits": supervisor.Contactno,
} {
_, err := svc.Login(models.PosLoginRequest{
Contactno: typed, Pin: strconv.FormatInt(supervisor.Pin, 10),
})
check("number accepted as typed", err == nil, label)
}
}
// And the refusals.
if supervisor != nil {
wrong := supervisor.Pin + 1
if wrong > 9999 {
wrong = 1000
}
_, err := svc.Login(models.PosLoginRequest{
Contactno: supervisor.Contactno, Pin: strconv.FormatInt(wrong, 10),
})
check("a wrong PIN is refused", err != nil, errText(err))
}
_, err = svc.Login(models.PosLoginRequest{Contactno: "9999999999", Pin: "4821"})
check("an unknown number is refused", err != nil, errText(err))
// Switching operator at an open terminal, which is what the till does when
// a colleague takes over.
if supSession != nil && cashier != nil && cashier.Locationid == supSession.Locationid {
switched, err := repo.PosLoginByPin(supSession.Tenantid, supSession.Locationid,
strconv.FormatInt(cashier.Pin, 10))
if err != nil {
check("operator switch by PIN", false, err.Error())
} else {
check("operator switch by PIN", true,
fmt.Sprintf("now %s, can_manage_staff=%v", switched.Role, switched.Canmanagestaff))
}
}
fmt.Printf("\n%d passed, %d failed\n", pass, fail)
if fail > 0 {
os.Exit(1)
}
}
// mask shows enough of a number to recognise the account, not enough to sign in
// as it.
func mask(number string, show bool) string {
if show {
return number
}
if len(number) != 10 {
return strings.Repeat("*", len(number))
}
return number[:2] + "******" + number[8:]
}
func maskPin(pin int64, show bool) string {
if show {
return strconv.FormatInt(pin, 10)
}
return "****"
}
func errText(err error) string {
if err == nil {
return "no error"
}
return err.Error()
}

View File

@@ -0,0 +1,166 @@
// Can the accounts that may open a till actually complete the new sign-in?
//
// Read-only, and deliberately prints no numbers and no PINs — only whether each
// account has one and whether the login would find it.
//
// The question this answers is narrower than "does it have a number". The login
// matches `LOWER(TRIM(contactno)) = LOWER(TRIM($1))` where $1 has already been
// reduced to ten digits, so the comparison is exact: a row holding
// "+91 98765 43210" is invisible to somebody typing the same number, because
// only one side of the comparison gets normalised.
//
// go run ./scratch/posloginready
package main
import (
"fmt"
"log"
"os"
"strings"
"nearle/models"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type row struct {
Userid int
Tenantid int
Locationid int
Roleid int
Contactno string
Pin int64
Status string
}
// normalise mirrors repositories.normalisePosPhone, which is unexported.
func normalise(raw string) string {
digits := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, raw)
if len(digits) == 12 && strings.HasPrefix(digits, "91") {
digits = digits[2:]
} else if len(digits) == 11 && strings.HasPrefix(digits, "0") {
digits = digits[1:]
}
if len(digits) != 10 {
return ""
}
return digits
}
func main() {
_ = godotenv.Load()
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
log.Fatal(err)
}
var rows []row
if err := db.Raw(`
SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
COALESCE(roleid,0) AS roleid, COALESCE(contactno,'') AS contactno,
COALESCE(pin,0) AS pin, COALESCE(status,'') AS status
FROM app_users
WHERE COALESCE(roleid,0) IN (?, ?)
ORDER BY tenantid, locationid, userid`,
models.PosRoleSupervisor, models.PosRoleCashier).Scan(&rows).Error; err != nil {
log.Fatal(err)
}
// What the login would see. Only active till accounts are candidates, and a
// number matching more than one of them is refused outright.
byPhone := map[string][]int{}
for _, r := range rows {
if strings.EqualFold(strings.TrimSpace(r.Status), "inactive") {
continue
}
if stored := strings.TrimSpace(r.Contactno); stored != "" {
byPhone[strings.ToLower(stored)] = append(byPhone[strings.ToLower(stored)], r.Userid)
}
}
fmt.Printf("till accounts (roleid %d/%d): %d\n\n", models.PosRoleSupervisor, models.PosRoleCashier, len(rows))
fmt.Printf("%-8s %-8s %-9s %-5s %-9s %-8s %-7s %s\n",
"userid", "tenant", "location", "role", "status", "number", "pin", "can sign in?")
fmt.Println(strings.Repeat("-", 86))
var ready, noPhone, unnormalised, noPin, ambiguous, inactive int
for _, r := range rows {
stored := strings.TrimSpace(r.Contactno)
norm := normalise(stored)
phoneState := "missing"
switch {
case stored == "":
phoneState = "missing"
case norm == "":
phoneState = "unusable"
case norm != stored:
phoneState = "STORED RAW"
default:
phoneState = "ok"
}
pinState := "missing"
if r.Pin >= 1000 && r.Pin <= 9999 {
pinState = "ok"
} else if r.Pin != 0 {
pinState = "unusable"
}
verdict := "yes"
switch {
case strings.EqualFold(r.Status, "inactive"):
verdict, inactive = "no — inactive", inactive+1
case stored == "":
verdict, noPhone = "no — no number", noPhone+1
case norm == "":
verdict, noPhone = "no — number unusable", noPhone+1
case norm != stored:
// The one that looks fine in the console and fails at the counter.
verdict, unnormalised = "NO — number stored unnormalised", unnormalised+1
case pinState != "ok":
verdict, noPin = "no — no usable PIN", noPin+1
case len(byPhone[strings.ToLower(stored)]) > 1:
verdict, ambiguous = "NO — number shared with another till account", ambiguous+1
default:
ready++
}
fmt.Printf("%-8d %-8d %-9d %-5d %-9s %-8s %-7s %s\n",
r.Userid, r.Tenantid, r.Locationid, r.Roleid, r.Status, phoneState, pinState, verdict)
}
fmt.Printf("\nready to sign in with number + PIN : %d of %d\n", ready, len(rows))
fmt.Printf(" blocked, no/unusable number : %d\n", noPhone)
fmt.Printf(" blocked, number stored raw : %d\n", unnormalised)
fmt.Printf(" blocked, no usable PIN : %d\n", noPin)
fmt.Printf(" blocked, number not unique : %d\n", ambiguous)
fmt.Printf(" inactive : %d\n", inactive)
// Cross-tenant collisions are the failure creation cannot prevent:
// posPhoneTaken scopes uniqueness to one tenant, the login does not scope at
// all, so two tenants may each hold a number that neither can then use.
fmt.Println("\nnumbers shared by more than one active till account:")
found := false
for _, users := range byPhone {
if len(users) > 1 {
found = true
fmt.Printf(" userids %v — all refused with \"more than one account uses these sign-in details\"\n", users)
}
}
if !found {
fmt.Println(" none")
}
}