Compare commits
15 Commits
bddd8fa265
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7895d3ccf | ||
|
|
9e9401215d | ||
|
|
c0a7fbc1b1 | ||
|
|
f5e16b54cc | ||
|
|
cd2459dbb6 | ||
|
|
6a62dbb9f3 | ||
|
|
f343f4e86e | ||
|
|
4b27b84b1f | ||
|
|
c696ec3e79 | ||
|
|
c4dfcd5387 | ||
|
|
12165d5e58 | ||
|
|
5864204d32 | ||
|
|
d0c3cb751e | ||
|
|
11595ad415 | ||
|
|
ec672a3087 |
@@ -1,14 +1,19 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/middleware"
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
|
"nearle/repositories"
|
||||||
"nearle/services"
|
"nearle/services"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -95,6 +100,63 @@ func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error {
|
|||||||
return c.Status(http.StatusOK).JSON(ack)
|
return c.Status(http.StatusOK).JSON(ack)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IngestHealth records one heartbeat from a till.
|
||||||
|
//
|
||||||
|
// The same heartbeat the broker carries, over HTTP, because presence was
|
||||||
|
// previously reachable *only* over MQTT — a terminal configured for the HTTP
|
||||||
|
// route reported bills perfectly and never appeared on the fleet board at all,
|
||||||
|
// with nothing anywhere to say why. A monitoring feature that silently does not
|
||||||
|
// exist on one of two supported transports is worse than no feature.
|
||||||
|
//
|
||||||
|
// Answers 202 rather than 200: nothing is committed, and the till is told not
|
||||||
|
// to wait on it. Failures are swallowed for the same reason the MQTT path
|
||||||
|
// swallows them — a terminal that cannot say how it is must still sell, and a
|
||||||
|
// blank square on a dashboard beats a till that stopped because Redis was busy.
|
||||||
|
func (ctl *PosController) IngestHealth(c *fiber.Ctx) error {
|
||||||
|
var health models.PosHealth
|
||||||
|
|
||||||
|
if err := c.BodyParser(&health); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "could not read the heartbeat: " + err.Error(),
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Over MQTT these come from the topic. There is no topic here, so the body
|
||||||
|
// is the only source and both are required — a heartbeat that cannot say
|
||||||
|
// which till it belongs to is unfilable.
|
||||||
|
if strings.TrimSpace(health.Terminalid) == "" {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "terminal_id is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(health.Locationid) == "" {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "location_id is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches the consumer: a bare {"status":"offline"} is a Last Will and must
|
||||||
|
// survive as-is, but an unset status from a till that is plainly talking to
|
||||||
|
// us means online.
|
||||||
|
if strings.TrimSpace(health.Status) == "" {
|
||||||
|
health.Status = "online"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.posService.RecordHealth(c.Context(), health); err != nil {
|
||||||
|
// Logged, not returned. See above — the till must not slow down for it.
|
||||||
|
log.Printf("pos: could not record heartbeat from %s/%s over HTTP: %v",
|
||||||
|
health.Locationid, health.Terminalid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusAccepted).JSON(fiber.Map{
|
||||||
|
"status": true, "code": http.StatusAccepted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Catalogue answers a terminal's product pull.
|
// Catalogue answers a terminal's product pull.
|
||||||
func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
|
func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
|
||||||
storeID := strings.TrimSpace(c.Query("store_id"))
|
storeID := strings.TrimSpace(c.Query("store_id"))
|
||||||
@@ -319,3 +381,452 @@ func posIngestError(c *fiber.Ctx, op string, err error) error {
|
|||||||
"status": false,
|
"status": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Login signs a terminal in and returns its session.
|
||||||
|
//
|
||||||
|
// The one POS route that is deliberately left unauthenticated — it is where a
|
||||||
|
// token comes from. Everything else on the group sits behind the session this
|
||||||
|
// issues.
|
||||||
|
func (ctl *PosController) Login(c *fiber.Ctx) error {
|
||||||
|
var req models.PosLoginRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.Authname) == "" && strings.TrimSpace(req.Contactno) == "" {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "an email or mobile number is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := ctl.posService.Login(req)
|
||||||
|
if err != nil {
|
||||||
|
// A rejected credential is 401 and says nothing about which half was
|
||||||
|
// wrong. Anything else is the deployment's problem, not the caller's,
|
||||||
|
// and is logged rather than described down the wire.
|
||||||
|
if repositories.PosLoginRejected(err) {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"code": http.StatusUnauthorized, "status": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("pos login (%s): %v", req.Authname, err)
|
||||||
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"code": http.StatusForbidden, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"message": "Login successful",
|
||||||
|
"details": session,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session echoes back who the caller is, per their token.
|
||||||
|
//
|
||||||
|
// What a till calls on start-up to find out whether the session it saved
|
||||||
|
// yesterday is still good, without having to make a real request and interpret
|
||||||
|
// the failure. Answers 401 through the middleware when it is not.
|
||||||
|
func (ctl *PosController) Session(c *fiber.Ctx) error {
|
||||||
|
claims, ok := middleware.PosClaimsFrom(c)
|
||||||
|
if !ok {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"code": http.StatusUnauthorized, "status": false,
|
||||||
|
"message": "no session token was presented",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"details": fiber.Map{
|
||||||
|
"user_id": claims.Userid,
|
||||||
|
"tenant_id": claims.Tenantid,
|
||||||
|
"location_id": claims.Locationid,
|
||||||
|
"store_id": strconv.Itoa(claims.Locationid),
|
||||||
|
"role_id": claims.Roleid,
|
||||||
|
"terminal_id": claims.Terminalid,
|
||||||
|
"expires_at": time.Unix(claims.Expiresat, 0).UTC().Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staff lists who may ring a bill at this terminal's outlet.
|
||||||
|
//
|
||||||
|
// Scoped by the caller's own session rather than by a query parameter. A till
|
||||||
|
// asking "who works here" must not be able to ask on behalf of another shop,
|
||||||
|
// and the answer carries PINs — so the outlet comes from the token, and a
|
||||||
|
// request without one is refused whatever POS_AUTH_REQUIRED says.
|
||||||
|
func (ctl *PosController) Staff(c *fiber.Ctx) error {
|
||||||
|
claims, ok := middleware.PosClaimsFrom(c)
|
||||||
|
if !ok {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"code": http.StatusUnauthorized, "status": false,
|
||||||
|
"message": "a session token is required to read staff",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
staff, err := ctl.posService.Staff(claims.Tenantid, claims.Locationid)
|
||||||
|
if err != nil {
|
||||||
|
return posServerError(c, "Staff", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"details": models.PosStaffResponse{
|
||||||
|
Locationid: claims.Locationid,
|
||||||
|
Staff: staff,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- Till staff
|
||||||
|
//
|
||||||
|
// A shop runs its own counter. A supervisor creates their cashiers from the
|
||||||
|
// terminal, and every one of these reads the tenant and outlet from the
|
||||||
|
// caller's session token rather than from the request — so a supervisor at one
|
||||||
|
// shop cannot create, edit or list staff at another. That is the same inversion
|
||||||
|
// that stopped a till naming its own store id, applied to people.
|
||||||
|
|
||||||
|
// posManager returns the caller's session, provided they may manage staff.
|
||||||
|
func posManager(c *fiber.Ctx) (utils.PosClaims, error) {
|
||||||
|
claims, ok := middleware.PosClaimsFrom(c)
|
||||||
|
if !ok {
|
||||||
|
return claims, fiber.NewError(http.StatusUnauthorized,
|
||||||
|
"a session token is required")
|
||||||
|
}
|
||||||
|
if !models.PosRoleCanManageStaff(claims.Roleid) {
|
||||||
|
// A cashier signing in on the same terminal must not be able to mint
|
||||||
|
// themselves a supervisor.
|
||||||
|
return claims, fiber.NewError(http.StatusForbidden,
|
||||||
|
"only a supervisor can manage till users")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePosUser adds a cashier or supervisor at the caller's outlet.
|
||||||
|
func (ctl *PosController) CreatePosUser(c *fiber.Ctx) error {
|
||||||
|
claims, err := posManager(c)
|
||||||
|
if err != nil {
|
||||||
|
return posClaimError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.PosUserRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := ctl.posService.CreateUser(claims.Tenantid, claims.Locationid, claims.Configid, req)
|
||||||
|
if err != nil {
|
||||||
|
// Every failure here is something the caller can act on — a bad role, a
|
||||||
|
// PIN already in use, a name left blank — so it is reported as a 400
|
||||||
|
// with the reason rather than logged and hidden behind a 500.
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||||
|
"code": http.StatusCreated, "status": true,
|
||||||
|
"message": "User created", "details": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePosUser edits one of the caller's own till users.
|
||||||
|
func (ctl *PosController) UpdatePosUser(c *fiber.Ctx) error {
|
||||||
|
claims, err := posManager(c)
|
||||||
|
if err != nil {
|
||||||
|
return posClaimError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.PosUserRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := ctl.posService.UpdateUser(claims.Tenantid, claims.Locationid, req)
|
||||||
|
if err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"message": "User updated", "details": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPosUsers returns the till users at the caller's outlet.
|
||||||
|
//
|
||||||
|
// Readable by anyone signed in, not only a supervisor: the terminal needs the
|
||||||
|
// list to show who is on shift, and a cashier can already see their colleagues
|
||||||
|
// standing next to them. PINs are the part that matters, and those only go to
|
||||||
|
// somebody who could set them anyway.
|
||||||
|
func (ctl *PosController) ListPosUsers(c *fiber.Ctx) error {
|
||||||
|
claims, ok := middleware.PosClaimsFrom(c)
|
||||||
|
if !ok {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"code": http.StatusUnauthorized, "status": false,
|
||||||
|
"message": "a session token is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := ctl.posService.ListUsers(
|
||||||
|
claims.Tenantid, claims.Locationid,
|
||||||
|
strings.EqualFold(c.Query("include_inactive"), "true"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return posServerError(c, "ListPosUsers", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cashier sees who is on shift, not how to sign in as them.
|
||||||
|
if !models.PosRoleCanManageStaff(claims.Roleid) {
|
||||||
|
for i := range users {
|
||||||
|
users[i].Pin = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"details": fiber.Map{"location_id": claims.Locationid, "users": users},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePosUser retires a till user. Deactivates rather than deletes — bills
|
||||||
|
// carry the cashier's name and shifts settle against it.
|
||||||
|
func (ctl *PosController) DeletePosUser(c *fiber.Ctx) error {
|
||||||
|
claims, err := posManager(c)
|
||||||
|
if err != nil {
|
||||||
|
return posClaimError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, convErr := strconv.Atoi(strings.TrimSpace(c.Query("user_id")))
|
||||||
|
if convErr != nil || userID <= 0 {
|
||||||
|
return posBadRequest(c, fmt.Errorf("user_id is required"))
|
||||||
|
}
|
||||||
|
if userID == claims.Userid {
|
||||||
|
// Otherwise the last supervisor at a shop can lock everybody out with
|
||||||
|
// one tap, and only we can undo it.
|
||||||
|
return posBadRequest(c, fmt.Errorf("you cannot deactivate the account you are signed in as"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.posService.DeactivateUser(claims.Tenantid, claims.Locationid, userID); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "User deactivated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PinLogin signs somebody in by PIN at a terminal that is already open.
|
||||||
|
//
|
||||||
|
// Requires an existing valid session, and that is the whole security model
|
||||||
|
// here: four digits is ten thousand guesses, which is no barrier at all to an
|
||||||
|
// anonymous caller. Tying it to a token means a supervisor has already opened
|
||||||
|
// the terminal with a real password, and the guesses are confined to one
|
||||||
|
// outlet's own staff.
|
||||||
|
//
|
||||||
|
// The new session is minted fresh rather than derived from the presented one,
|
||||||
|
// so a cashier taking over from a supervisor drops the supervisor's
|
||||||
|
// permissions instead of inheriting them.
|
||||||
|
func (ctl *PosController) PinLogin(c *fiber.Ctx) error {
|
||||||
|
claims, ok := middleware.PosClaimsFrom(c)
|
||||||
|
if !ok {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"code": http.StatusUnauthorized, "status": false,
|
||||||
|
"message": "sign the terminal in with an email and password before using PIN sign-in",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.PosLoginRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Pin) == "" {
|
||||||
|
return posBadRequest(c, fmt.Errorf("a PIN is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := ctl.posService.LoginWithPin(claims.Tenantid, claims.Locationid, req.Pin)
|
||||||
|
if err != nil {
|
||||||
|
if repositories.PosLoginRejected(err) {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"code": http.StatusUnauthorized, "status": false,
|
||||||
|
"message": "that PIN was not recognised",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"message": "Signed in", "details": session,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// posClaimError renders the fiber.Error that posManager returns.
|
||||||
|
func posClaimError(c *fiber.Ctx, err error) error {
|
||||||
|
var fe *fiber.Error
|
||||||
|
if errors.As(err, &fe) {
|
||||||
|
return c.Status(fe.Code).JSON(fiber.Map{
|
||||||
|
"code": fe.Code, "status": false, "message": fe.Message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return posServerError(c, "posClaims", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- Till staff, from the web
|
||||||
|
//
|
||||||
|
// The same staff management as `/pos/users`, for the console an admin actually
|
||||||
|
// uses. Deliberately the same service calls underneath rather than a parallel
|
||||||
|
// implementation: a supervisor created from a browser must be the same thing as
|
||||||
|
// one created at a counter, and two code paths writing one table is exactly how
|
||||||
|
// that stops being true.
|
||||||
|
//
|
||||||
|
// The difference is where the outlet comes from. A terminal proves it with a
|
||||||
|
// signed token; the console asserts it, because it has no session of its own.
|
||||||
|
// So it is verified against the tenant before anything is written — which is
|
||||||
|
// weaker than a signature, and is why these should move behind the same guard
|
||||||
|
// once the console can hold a session.
|
||||||
|
|
||||||
|
// posWebScope reads and checks the tenant and outlet a console request names.
|
||||||
|
func (ctl *PosController) posWebScope(tenantID, locationID int) error {
|
||||||
|
if tenantID <= 0 {
|
||||||
|
return fmt.Errorf("tenantid is required")
|
||||||
|
}
|
||||||
|
if locationID <= 0 {
|
||||||
|
return fmt.Errorf("locationid is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := ctl.posService.LocationAllowed(tenantID, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not verify the outlet")
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
// Not "no such outlet" — that would confirm which ids exist. It did not
|
||||||
|
// belong to the tenant asking, and that is all the caller needs.
|
||||||
|
return fmt.Errorf("outlet %d does not belong to tenant %d", locationID, tenantID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebCreatePosUser adds a supervisor or cashier from the console.
|
||||||
|
func (ctl *PosController) WebCreatePosUser(c *fiber.Ctx) error {
|
||||||
|
var req models.PosUserWebRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The configid the outlet's other people already use, so a new cashier is
|
||||||
|
// visible to the same portal as their colleagues. Asked for rather than
|
||||||
|
// derived would mean a console sending a number nobody can look up.
|
||||||
|
configID := ctl.posService.ConfigidFor(req.Tenantid)
|
||||||
|
|
||||||
|
user, err := ctl.posService.CreateUser(req.Tenantid, req.Locationid, configID, req.PosUserRequest)
|
||||||
|
if err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||||
|
"code": http.StatusCreated, "status": true,
|
||||||
|
"message": "User created", "details": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebUpdatePosUser edits one of an outlet's till users from the console.
|
||||||
|
func (ctl *PosController) WebUpdatePosUser(c *fiber.Ctx) error {
|
||||||
|
var req models.PosUserWebRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := ctl.posService.UpdateUser(req.Tenantid, req.Locationid, req.PosUserRequest)
|
||||||
|
if err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"message": "User updated", "details": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebListPosUsers lists an outlet's till users for the console.
|
||||||
|
func (ctl *PosController) WebListPosUsers(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
|
||||||
|
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
||||||
|
|
||||||
|
if err := ctl.posWebScope(tenantID, locationID); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := ctl.posService.ListUsers(tenantID, locationID,
|
||||||
|
strings.EqualFold(c.Query("include_inactive"), "true"))
|
||||||
|
if err != nil {
|
||||||
|
return posServerError(c, "WebListPosUsers", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"details": fiber.Map{"location_id": locationID, "users": users},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebDeletePosUser retires a till user from the console.
|
||||||
|
func (ctl *PosController) WebDeletePosUser(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
|
||||||
|
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
||||||
|
|
||||||
|
if err := ctl.posWebScope(tenantID, locationID); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := strconv.Atoi(strings.TrimSpace(c.Query("userid")))
|
||||||
|
if err != nil || userID <= 0 {
|
||||||
|
return posBadRequest(c, fmt.Errorf("userid is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.posService.DeactivateUser(tenantID, locationID, userID); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "User deactivated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebPosRoles lists the roles a console may offer.
|
||||||
|
//
|
||||||
|
// Served rather than hardcoded in the console, because the numbers are this
|
||||||
|
// backend's business. A console that hardcoded 7 and 8 would be wrong the day
|
||||||
|
// they change, and would have no way to know.
|
||||||
|
func (ctl *PosController) WebPosRoles(c *fiber.Ctx) error {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"details": []fiber.Map{
|
||||||
|
{
|
||||||
|
"role_id": models.PosRoleSupervisor, "role": "supervisor",
|
||||||
|
"label": models.PosRoleName(models.PosRoleSupervisor),
|
||||||
|
"description": "Runs the terminal: imports, settings, voids, and " +
|
||||||
|
"creating counter staff. Signs in at a till only — a till " +
|
||||||
|
"account has no Nearle Daily login.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_id": models.PosRoleCashier, "role": "cashier",
|
||||||
|
"label": models.PosRoleName(models.PosRoleCashier),
|
||||||
|
"description": "Billing only. Signs in at a till with their own username " +
|
||||||
|
"and password, so a shop can open without a supervisor present.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -328,8 +328,12 @@ func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := ctl.tenantService.CreateStaff(data); err != nil {
|
if err := ctl.tenantService.CreateStaff(data); err != nil {
|
||||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
// A rejected PIN, a missing name, a role nobody set — these are things
|
||||||
"code": http.StatusConflict,
|
// the person filling in the form can fix, so they come back as 400 with
|
||||||
|
// the reason. This answered 500 with a body claiming 409, which told a
|
||||||
|
// console nothing it could act on and told the operator less.
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
"message": err.Error(),
|
"message": err.Error(),
|
||||||
"status": false,
|
"status": false,
|
||||||
})
|
})
|
||||||
|
|||||||
625
docs/POS_LOGIN.md
Normal file
625
docs/POS_LOGIN.md
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
# Nearle POS — Terminal Sign-In
|
||||||
|
|
||||||
|
How a till authenticates, and how it finds out which shop it belongs to.
|
||||||
|
|
||||||
|
**Base URL** `https://fiesta.nearle.app/live/api/v1/pos`
|
||||||
|
**Live since** 6 Aug 2026, `v1.3.98`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed, and why it matters
|
||||||
|
|
||||||
|
A terminal used to hold a store id typed into Settings and a password compiled
|
||||||
|
into the app. That made the store id a **claim** rather than a fact: any till
|
||||||
|
could name any outlet and be believed, so changing one number on one screen
|
||||||
|
moved a terminal into another tenant's books. The password was identical on
|
||||||
|
every install of a build.
|
||||||
|
|
||||||
|
Now a person signs in with their own back-office account, and the outlet
|
||||||
|
arrives **as a consequence** — sealed inside a signed token the terminal cannot
|
||||||
|
edit, and re-checked by the server on every request.
|
||||||
|
|
||||||
|
The rule to hold onto: **the till no longer decides which shop it is. It is
|
||||||
|
told.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE=https://fiesta.nearle.app/live/api/v1/pos
|
||||||
|
|
||||||
|
# 1. Sign in
|
||||||
|
curl -s -X POST $BASE/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"authname":"rsselvapuram@gmail.com","password":"…","terminal_id":"T5EDD"}'
|
||||||
|
|
||||||
|
# 2. Use the token on everything else
|
||||||
|
curl -s $BASE/session -H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The flow
|
||||||
|
|
||||||
|
These steps are in order, and the order matters.
|
||||||
|
|
||||||
|
**1. Sign in.** `POST /login` with the operator's own credentials — the same
|
||||||
|
`app_users` account they use for the web console. There is no separate POS
|
||||||
|
password.
|
||||||
|
|
||||||
|
**2. Read `store_id` out of the response.** Do not ask anyone to type it. It is
|
||||||
|
whatever the back office says that account's outlet is.
|
||||||
|
|
||||||
|
**3. If `locations` has more than one entry, ask which one.** Only then. A
|
||||||
|
single-outlet account gets a list of one and must never see a picker.
|
||||||
|
|
||||||
|
**4. Save the token.** Platform keystore, not a plain file or SQLite — it is a
|
||||||
|
bearer credential for a whole trading day. Restore it on launch **before** any
|
||||||
|
upload or catalogue pull runs.
|
||||||
|
|
||||||
|
**5. Send it on every request** as `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
**6. Import `staff`.** Replace the till's local staff with what came down, and
|
||||||
|
deactivate anything that wasn't in the list. That is what retires the built-in
|
||||||
|
PINs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `POST /login`
|
||||||
|
|
||||||
|
The only unauthenticated route. It is where a token comes from.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authname": "rsselvapuram@gmail.com",
|
||||||
|
"password": "…",
|
||||||
|
"terminal_id": "T5EDD",
|
||||||
|
"device_id": "a5f3…",
|
||||||
|
"location_id": 1135,
|
||||||
|
"configid": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `authname` | yes* | Email. **Or** send `contactno` instead. |
|
||||||
|
| `contactno` | yes* | Mobile number, as an alternative to `authname`. |
|
||||||
|
| `password` | yes | |
|
||||||
|
| `terminal_id` | no | This till's short code, e.g. `T5EDD`. Recorded on the session. |
|
||||||
|
| `device_id` | no | The device's stable UUID. |
|
||||||
|
| `location_id` | no | **Only** meaningful for a multi-outlet account. A request, not an assertion — it is checked against what the account may reach. |
|
||||||
|
| `configid` | no | Inferred when absent. Send it only if you get the ambiguity error below. |
|
||||||
|
|
||||||
|
\* one of `authname` or `contactno`.
|
||||||
|
|
||||||
|
### Response — `200`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"status": true,
|
||||||
|
"message": "Login successful",
|
||||||
|
"details": {
|
||||||
|
"token": "eyJ1aWQiOjEy….K3p9",
|
||||||
|
"expires_at": "2026-09-05T10:51:17Z",
|
||||||
|
|
||||||
|
"user_id": 1229,
|
||||||
|
"full_name": "Selvapuram",
|
||||||
|
"email": "rsselvapuram@gmail.com",
|
||||||
|
"role_id": 0,
|
||||||
|
|
||||||
|
"tenant_id": 1087,
|
||||||
|
"tenant_name": "Ragul Stores",
|
||||||
|
|
||||||
|
"store_id": "1135",
|
||||||
|
"location_id": 1135,
|
||||||
|
"location_name": "Ragul stores Selvapuram",
|
||||||
|
"gstin": "123456",
|
||||||
|
"address": "…",
|
||||||
|
"phone": "…",
|
||||||
|
|
||||||
|
"locations": [
|
||||||
|
{ "location_id": 1135, "location_name": "Ragul stores Selvapuram",
|
||||||
|
"address": "", "city": "", "status": "Active" }
|
||||||
|
],
|
||||||
|
|
||||||
|
"staff": [
|
||||||
|
{ "user_id": 1148, "full_name": "Ragul Kannan",
|
||||||
|
"role": "Super admin", "pin": "1111", "status": "Active" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### The fields that matter
|
||||||
|
|
||||||
|
**`store_id`** — a string, because that is the shape every uplink already
|
||||||
|
sends. Use it verbatim as the `store_id` on `/orders`, `/customers` and
|
||||||
|
`/catalogue`. It is the same value as `location_id`, handed back in the form it
|
||||||
|
will be replayed in.
|
||||||
|
|
||||||
|
**`token`** — **opaque**. Do not parse it, do not read anything out of it, do
|
||||||
|
not trust anything it appears to say. Its only correct use is to hand it back.
|
||||||
|
|
||||||
|
**`expires_at`** — 30 days out. Long on purpose: a shop signs a terminal in once
|
||||||
|
and expects it to keep working. Forcing a re-login mid-shift means a queue of
|
||||||
|
customers waiting while somebody finds the manager.
|
||||||
|
|
||||||
|
**`gstin` / `address` / `phone`** — print these on the receipt. They are a legal
|
||||||
|
requirement on a GST invoice and they used to be compile-time constants, so a
|
||||||
|
shop correcting its GSTIN had to wait for a rebuild. Write them locally on
|
||||||
|
sign-in.
|
||||||
|
|
||||||
|
**`locations`** — every outlet this account may open a till at. Length 1 is the
|
||||||
|
normal case.
|
||||||
|
|
||||||
|
**`staff`** — see [Staff and PINs](#staff-and-pins). **Often empty.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `GET /session`
|
||||||
|
|
||||||
|
Answers who the caller is, per their token. What a till calls on launch to
|
||||||
|
check whether yesterday's session is still good, without making a real request
|
||||||
|
and interpreting the failure.
|
||||||
|
|
||||||
|
Requires the token. Returns `401` when there isn't one.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"status": true,
|
||||||
|
"details": {
|
||||||
|
"user_id": 1229,
|
||||||
|
"tenant_id": 1087,
|
||||||
|
"location_id": 1135,
|
||||||
|
"store_id": "1135",
|
||||||
|
"role_id": 0,
|
||||||
|
"terminal_id": "PROBE",
|
||||||
|
"expires_at": "2026-09-05T10:51:17Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `GET /staff`
|
||||||
|
|
||||||
|
Who may ring a bill at this terminal's outlet. For pulling down somebody hired
|
||||||
|
mid-shift without signing the terminal out.
|
||||||
|
|
||||||
|
**Takes no parameters.** The answer carries PINs, so the outlet comes from the
|
||||||
|
caller's own token — a till must not be able to ask who works at the shop next
|
||||||
|
door. A request without a token is refused whatever the enforcement setting is.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"status": true,
|
||||||
|
"details": {
|
||||||
|
"location_id": 1135,
|
||||||
|
"staff": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
Two POS roles, added to `app_roles`:
|
||||||
|
|
||||||
|
| roleid | Role | Can |
|
||||||
|
|---|---|---|
|
||||||
|
| `7` | **Supervisor** | everything a till does, **plus** creating and editing counter staff |
|
||||||
|
| `8` | **Cashier** | billing only |
|
||||||
|
|
||||||
|
The session carries both, so the terminal never has to map role ids itself:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "role_id": 7, "role": "Supervisor", "can_manage_staff": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch on `can_manage_staff`, not on the number. `app_roles` holds six rows for
|
||||||
|
four back-office roles (Admin is both 3 and 5, Manager both 4 and 6) and most
|
||||||
|
accounts carry an id that is not in the table at all — any mapping written on
|
||||||
|
the terminal would be wrong.
|
||||||
|
|
||||||
|
### The till and Nearle Daily do not share accounts
|
||||||
|
|
||||||
|
`app_users` is the only thing the two products have in common. An account
|
||||||
|
belongs to one or the other, never to both:
|
||||||
|
|
||||||
|
| | Nearle Daily app + console | POS terminal |
|
||||||
|
|---|---|---|
|
||||||
|
| roles | `1`–`6` — Super admin, Operations, Admin, Manager | `7` Supervisor, `8` Cashier |
|
||||||
|
| `/applogin`, `/tenant/weblogin`, `/tenant/login` | yes | **not found** |
|
||||||
|
| `POST /v1/pos/login` | **403** | yes |
|
||||||
|
| listed by `/getallusers`, `/getstaffs` | yes | **hidden** |
|
||||||
|
|
||||||
|
A Nearle Daily **Super admin is not the administrator of anybody's POS.** The
|
||||||
|
back office reaches a till by *provisioning* a Supervisor from the console; it
|
||||||
|
never becomes one by signing in.
|
||||||
|
|
||||||
|
This was the other way round until it was measured. Roles 1–6 counted as
|
||||||
|
supervisors, on the reasoning that somebody who already administers a shop from
|
||||||
|
a browser is not made less privileged by standing at the counter. That handed
|
||||||
|
till-supervisor powers to **68 live accounts, 59 of them platform Super
|
||||||
|
admins**, while the actual shop accounts carry `roleid 0` and were refused.
|
||||||
|
|
||||||
|
Both directions are now closed in the queries themselves rather than in a check
|
||||||
|
each call site has to remember — a till account is not *rejected* by the app
|
||||||
|
login, it is simply not found.
|
||||||
|
|
||||||
|
**`role_id` 0 is not a role.** It is what an account carries when nobody set
|
||||||
|
one, 22 live accounts have it including a delivery rider, and it grants nothing
|
||||||
|
on either side.
|
||||||
|
|
||||||
|
### Every till account gets its own username and password
|
||||||
|
|
||||||
|
Both roles. A PIN cannot open a *closed* terminal — `/pos/login/pin` requires a
|
||||||
|
session that already exists — so a PIN-only account works only while somebody
|
||||||
|
else is standing there to unlock the till first. For a Supervisor that was an
|
||||||
|
outright deadlock; for a Cashier it meant a shop that could not open until two
|
||||||
|
people had arrived, and whoever gets in at seven is as often the cashier as the
|
||||||
|
supervisor.
|
||||||
|
|
||||||
|
So a Cashier signs in exactly like a Supervisor does, and the *role* decides
|
||||||
|
what they get — not which credential they used:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/pos/login supervisor.1185@pos.nearle.in -> full shell
|
||||||
|
POST /v1/pos/login cashier.1185@pos.nearle.in -> billing only
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /pos/users` generates both when the request omits them, and returns the
|
||||||
|
password **once**, in the creation response only:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "user_id": 1452, "role": "Cashier",
|
||||||
|
"authname": "cashier.1185@pos.nearle.in",
|
||||||
|
"password": "9tWx2KUJksM5Rm", "pin": "4513", "has_password": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /pos/users` never returns a password, only `has_password`. An admin who
|
||||||
|
loses it reissues rather than looks it up.
|
||||||
|
|
||||||
|
Send `authname` and `password` explicitly if the shop wants its people signing
|
||||||
|
in as themselves. A generated name that collides — a second cashier at one
|
||||||
|
outlet — becomes `cashier2.1185@pos.nearle.in`; a name **you** supplied is never
|
||||||
|
adjusted, it is refused, because silently signing somebody in as another
|
||||||
|
person's address is worse than an error.
|
||||||
|
|
||||||
|
The PIN stays optional. It switches operator at an open counter, which not every
|
||||||
|
shop does, and it is the one credential the till holds in plaintext to hand
|
||||||
|
around — so it is set deliberately, never by default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `POST /pos/login/pin` — signing on at an open terminal
|
||||||
|
|
||||||
|
For a cashier taking over a counter a supervisor has already opened.
|
||||||
|
|
||||||
|
**Requires an existing valid token.** That is the security model, not an
|
||||||
|
oversight: four digits is ten thousand guesses, which is no barrier at all to an
|
||||||
|
anonymous caller. Tying it to a session means a supervisor has opened the
|
||||||
|
terminal with a real password first, and the guesses are confined to that one
|
||||||
|
outlet's staff.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST $BASE/login/pin \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"pin":"1602"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns a **new** session, with the same shape as `/login`. New rather than
|
||||||
|
reused, because the token carries the role — a cashier taking over from a
|
||||||
|
supervisor must drop their permissions, not inherit them.
|
||||||
|
|
||||||
|
`401` if the PIN is not recognised. `400` if two people at the outlet share it,
|
||||||
|
which creation refuses but older data may contain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `/pos/users` — the shop's own counter staff
|
||||||
|
|
||||||
|
A supervisor creates their own cashiers, from the terminal.
|
||||||
|
|
||||||
|
**The outlet is never in the request.** Tenant and location come from the
|
||||||
|
caller's token, so a supervisor at Selvapuram cannot create staff at R mart by
|
||||||
|
sending a different number — the same inversion that stopped a till naming its
|
||||||
|
own store id.
|
||||||
|
|
||||||
|
### `POST /pos/users`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"full_name": "Asha Kumar",
|
||||||
|
"role": "cashier",
|
||||||
|
"pin": "4821",
|
||||||
|
"authname": "asha@shop.test",
|
||||||
|
"password": "…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `full_name` | required; split across `firstname`/`lastname` |
|
||||||
|
| `role` | `"supervisor"` or `"cashier"`. Anything else is refused — never defaulted |
|
||||||
|
| `pin` | optional, 4 digits. See the rules below |
|
||||||
|
| `authname` | optional. **Generated if omitted** — `cashier.1185@pos.nearle.in`, or `cashier2.…` if that is taken |
|
||||||
|
| `password` | optional. **Generated if omitted**, and returned once in this response |
|
||||||
|
|
||||||
|
**Everyone gets a username and a password, cashiers included**, because a PIN
|
||||||
|
cannot open a closed terminal. Omit both fields and they are generated for you,
|
||||||
|
so provisioning a shop is one call per person.
|
||||||
|
|
||||||
|
The response is the only time the password is returned; `GET /pos/users` reports
|
||||||
|
`has_password` and nothing more.
|
||||||
|
|
||||||
|
:warning: **PIN rules, and why**
|
||||||
|
|
||||||
|
- **Exactly 4 digits, and cannot start with `0`.** `app_users.pin` is a
|
||||||
|
`bigint`, so `"0451"` would be stored as `451` and read back as three digits —
|
||||||
|
a cashier would type four and be refused for ever. One such account already
|
||||||
|
exists in live data.
|
||||||
|
- **`1234`, `1111`, `2345`, `4321`, `9999`, `2222`, `3456`, `0000` are refused.**
|
||||||
|
Live data has `1234` on eleven accounts and `1111` on nine.
|
||||||
|
- **Unique within the outlet**, not globally. A PIN only distinguishes people at
|
||||||
|
one counter; making it platform-unique would exhaust the space fast.
|
||||||
|
|
||||||
|
Answers `201` with the created user. Every failure is a `400` carrying the
|
||||||
|
reason, because all of them are things the caller can fix.
|
||||||
|
|
||||||
|
### `GET /pos/users`
|
||||||
|
|
||||||
|
Readable by anyone signed in — the terminal needs it to show who is on shift.
|
||||||
|
**A cashier gets the list with `pin` blanked**; only somebody who could set a
|
||||||
|
PIN gets to see one. `?include_inactive=true` to see leavers.
|
||||||
|
|
||||||
|
### `PUT /pos/users`
|
||||||
|
|
||||||
|
Same fields plus `user_id`. Send only what changes. Supervisor only.
|
||||||
|
|
||||||
|
### `DELETE /pos/users?user_id=9189`
|
||||||
|
|
||||||
|
Deactivates — never deletes, because bills carry the cashier's name and shifts
|
||||||
|
settle against it. Supervisor only, and you cannot deactivate the account you
|
||||||
|
are signed in as: otherwise the last supervisor at a shop can lock everyone out
|
||||||
|
with one tap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating staff from the web console
|
||||||
|
|
||||||
|
The same staff management, for the screen an admin actually uses. Registered
|
||||||
|
under both `/v1/web/tenants` and `/v1/mob/tenants`.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/web/tenants/posroles
|
||||||
|
GET /v1/web/tenants/getposusers?tenantid=1087&locationid=1135
|
||||||
|
POST /v1/web/tenants/createposuser
|
||||||
|
PUT /v1/web/tenants/updateposuser
|
||||||
|
DELETE /v1/web/tenants/deleteposuser?tenantid=1087&locationid=1135&userid=9189
|
||||||
|
```
|
||||||
|
|
||||||
|
`createposuser` takes the same body as `/pos/users`, plus the outlet — the
|
||||||
|
console has no session token, so it has to name one:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenantid": 1087,
|
||||||
|
"locationid": 1135,
|
||||||
|
"full_name": "Asha Kumar",
|
||||||
|
"role": "cashier",
|
||||||
|
"pin": "4821"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**These run the same service calls as `/pos/users`.** A supervisor created from
|
||||||
|
a browser is the same row, with the same rules applied, as one created at a
|
||||||
|
counter — same PIN validation, same duplicate check, same identity-column
|
||||||
|
allocation. That is the point of them: two paths writing one table is how the
|
||||||
|
two stop matching.
|
||||||
|
|
||||||
|
`configid` is never asked for. It is inferred from whichever value the tenant's
|
||||||
|
existing accounts carry — a number nobody looks up, that varies per tenant (1087
|
||||||
|
is spread across 1, 6 and 15), and that silently creates an account nobody can
|
||||||
|
find if it is wrong.
|
||||||
|
|
||||||
|
`GET /posroles` returns the two roles with their ids and labels, so a console
|
||||||
|
offering the choice never has to know that supervisor is `7`.
|
||||||
|
|
||||||
|
### :red_circle: These are unauthenticated
|
||||||
|
|
||||||
|
Like every other route in the `/v1/web` and `/v1/mob` groups — there is no auth
|
||||||
|
middleware anywhere on the web API. The outlet is checked against the tenant
|
||||||
|
before anything is written, so a caller cannot create staff at a shop that is
|
||||||
|
not theirs *given a tenant id* — but nothing proves the caller is that tenant.
|
||||||
|
|
||||||
|
So this mints till credentials on an unauthenticated request. It is consistent
|
||||||
|
with the rest of the platform, and it is still the weakest point in this design.
|
||||||
|
They should move behind a session guard as soon as the console can hold one.
|
||||||
|
|
||||||
|
The terminal routes are not affected: `/pos/users` proves its outlet with a
|
||||||
|
signed token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using the token
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer eyJ1aWQiOjEy….K3p9
|
||||||
|
```
|
||||||
|
|
||||||
|
`X-Pos-Token: <token>` is accepted as a fallback, because some shop routers
|
||||||
|
strip `Authorization` headers over plain HTTP. A bare token with no `Bearer `
|
||||||
|
prefix is tolerated too.
|
||||||
|
|
||||||
|
Send it on **every** POS call: `/orders`, `/customers`, `/catalogue`, `/health`,
|
||||||
|
`/sales*`, `/session`, `/staff`.
|
||||||
|
|
||||||
|
### What the server checks
|
||||||
|
|
||||||
|
1. The token verifies against our signing key and has not expired.
|
||||||
|
2. The outlet named in the request belongs to the token's tenant.
|
||||||
|
|
||||||
|
The second is the one that matters. A valid token is a licence to name **your**
|
||||||
|
outlets, not any outlet. The outlet is read from the query string *and* from the
|
||||||
|
JSON body, because `/orders` and `/customers` carry `store_id` in the batch and
|
||||||
|
never in the URL.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /catalogue?store_id=1135 → 200 your outlet
|
||||||
|
GET /catalogue?store_id=1185 → 403 {"message":"this session cannot reach outlet 1185"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
### Sign-in
|
||||||
|
|
||||||
|
| Code | Meaning | What the till should do |
|
||||||
|
|---|---|---|
|
||||||
|
| `400` | Body unreadable, or neither `authname` nor `contactno` sent | Fix the request |
|
||||||
|
| `401` | `those sign-in details were not recognised` | Ask them to re-type. **Wrong email and wrong password give the same message** — deliberately, so the endpoint isn't a directory of who banks here |
|
||||||
|
| `403` | Real account, but it can't open this till | Show the message; re-typing won't help |
|
||||||
|
|
||||||
|
The `403` messages, verbatim:
|
||||||
|
|
||||||
|
- `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`
|
||||||
|
- `this account is inactive; contact your administrator`
|
||||||
|
- `this account has no password set; set one in the web console first`
|
||||||
|
- `this account is not attached to a tenant and cannot open a till`
|
||||||
|
- `no active outlet is registered for this account`
|
||||||
|
- `this account cannot open a till at outlet 1185`
|
||||||
|
- `more than one account uses these sign-in details; ask your administrator for the configid and send it with the login`
|
||||||
|
|
||||||
|
That last one is real, not theoretical: `authname` is not unique in this schema.
|
||||||
|
Live data has the same address twice. We refuse rather than pick one, because
|
||||||
|
picking wrong means billing into another tenant's books.
|
||||||
|
|
||||||
|
The **first** one is the common case now, and it is deliberately specific where a
|
||||||
|
bad password is deliberately vague. By the time it fires the caller has already
|
||||||
|
proved the credential, so naming the reason leaks nothing they did not just
|
||||||
|
demonstrate — and the vague answer would send a shop owner hunting for a
|
||||||
|
password that was never wrong.
|
||||||
|
|
||||||
|
### Authenticated routes
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `401` | No token, malformed token, bad signature, or expired — sign in again |
|
||||||
|
| `403` | Valid token naming an outlet the tenant doesn't own |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-outlet accounts
|
||||||
|
|
||||||
|
An account pinned to one location gets that location. An account with no
|
||||||
|
location — a proprietor with several shops — gets all of the tenant's active
|
||||||
|
outlets.
|
||||||
|
|
||||||
|
```
|
||||||
|
rsselvapuram@gmail.com → 1 outlet (1135, Selvapuram)
|
||||||
|
raguladmin@gmail.com → 6 outlets (1097, 1135, 1137, 1138, 1139, 885536644)
|
||||||
|
```
|
||||||
|
|
||||||
|
When `locations.length > 1`:
|
||||||
|
|
||||||
|
1. Show a picker. **Don't make it dismissable** — a terminal has to be standing
|
||||||
|
somewhere, and silently defaulting to the first outlet is how a day's takings
|
||||||
|
get filed against the wrong shop.
|
||||||
|
2. Sign in **again** with `location_id` set to their choice.
|
||||||
|
|
||||||
|
Re-signing-in is not laziness. The outlet is inside the signed token, so only
|
||||||
|
the server can issue one for a different shop — and re-checking entitlement at
|
||||||
|
that moment is the point.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Staff and PINs
|
||||||
|
|
||||||
|
Two different credentials, easily confused:
|
||||||
|
|
||||||
|
| | Says | Checked by |
|
||||||
|
|---|---|---|
|
||||||
|
| **Sign-in** (email + password) | which **shop** this terminal is | the server |
|
||||||
|
| **PIN** | which **person** rang this bill | the terminal, offline |
|
||||||
|
|
||||||
|
The PIN stamps `cashiername` and is what shifts settle against. It is **shift
|
||||||
|
attribution, not a security boundary** — the boundary is the token.
|
||||||
|
|
||||||
|
### The PIN comes down in the clear
|
||||||
|
|
||||||
|
Over TLS, and that's considered rather than sloppy. Four digits are
|
||||||
|
brute-forceable in microseconds whatever they're wrapped in, so hashing
|
||||||
|
server-side would buy the appearance of strength and not the substance — while
|
||||||
|
costing something real, because the terminal salts every PIN with its own random
|
||||||
|
salt before storing it and could never verify a hash computed on the server.
|
||||||
|
|
||||||
|
**Store it hashed on the device.** It arrives in the clear; it must not sit that
|
||||||
|
way.
|
||||||
|
|
||||||
|
### Importing
|
||||||
|
|
||||||
|
Write everyone in `staff`, keyed on `user_id` so a re-sync updates rather than
|
||||||
|
duplicates. Then **deactivate everything you didn't just import** — that is what
|
||||||
|
kills the built-in PINs. Deactivate, never delete: bills carry the cashier's
|
||||||
|
name.
|
||||||
|
|
||||||
|
### :warning: `staff` is usually empty today
|
||||||
|
|
||||||
|
Only 116 of 596 accounts on the platform have a PIN set. Outlet 1135 — the one
|
||||||
|
the terminal ships pointed at — has **zero**.
|
||||||
|
|
||||||
|
So:
|
||||||
|
|
||||||
|
- **An empty list is not a failure.** Do nothing and leave the till exactly as
|
||||||
|
it was.
|
||||||
|
- **A list where every PIN is unusable** (`0`, blank) must behave the same way.
|
||||||
|
Deactivating the local accounts because the back office isn't filled in yet
|
||||||
|
would leave a counter nobody can sign in to.
|
||||||
|
|
||||||
|
The terminal still ships with three seeded logins for exactly this reason. They
|
||||||
|
retire automatically the moment real staff exist. Filling in real PINs in the
|
||||||
|
back office is what makes that happen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Endpoints | live on `v1.3.98`, all three pods |
|
||||||
|
| Signing key | set in `app-secrets` |
|
||||||
|
| **Enforcement** | **OFF** — `POS_AUTH_REQUIRED` is unset |
|
||||||
|
|
||||||
|
Enforcement being off means a request carrying **no** token is still allowed
|
||||||
|
through, so terminals already trading don't stop the day this ships. It does
|
||||||
|
**not** mean tokens are ignored:
|
||||||
|
|
||||||
|
- a token that's present and invalid is **always** refused;
|
||||||
|
- a valid token naming another tenant's outlet is **always** refused.
|
||||||
|
|
||||||
|
Once the fleet is on a build that signs in, `POS_AUTH_REQUIRED=true` closes the
|
||||||
|
door on untokened requests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **Passwords are stored in plaintext** across the whole platform, not just
|
||||||
|
here. Fixing it is a migration touching every login path.
|
||||||
|
- **No role check.** Any active account with a tenant, a password and an active
|
||||||
|
outlet can open a till — including `roleid 0`, which isn't in `app_roles` at
|
||||||
|
all and currently includes a delivery rider. The damage is bounded by the
|
||||||
|
token: they can only reach their own tenant's books.
|
||||||
|
- **`1135` means two different things.** It's a *location* (Ragul stores
|
||||||
|
Selvapuram, under tenant 1087) and separately a *tenant* (Suriya Store). Same
|
||||||
|
number, different tables. Watch for it in logs.
|
||||||
@@ -68,6 +68,45 @@ func (f *fakePosService) SalesSummary(models.PosSalesFilter) (*models.PosSalesSu
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sign-in plays no part over the broker: a terminal on MQTT authenticates to
|
||||||
|
// the broker itself, and the topic it publishes on already names its store.
|
||||||
|
// These exist to satisfy the interface, and returning "denied" is the safer
|
||||||
|
// stub — a fake that waved authorisation through could hide a real regression.
|
||||||
|
func (f *fakePosService) Login(models.PosLoginRequest) (*models.PosSession, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePosService) LocationAllowed(int, int) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePosService) Staff(int, int) ([]models.PosStaffMember, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staff management plays no part over the broker — a terminal on MQTT publishes
|
||||||
|
// bills and nothing else. Denied rather than permitted, so a fake cannot hide a
|
||||||
|
// regression by waving authorisation through.
|
||||||
|
func (f *fakePosService) CreateUser(int, int, int, models.PosUserRequest) (*models.PosUser, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePosService) UpdateUser(int, int, models.PosUserRequest) (*models.PosUser, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePosService) ListUsers(int, int, bool) ([]models.PosUser, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePosService) DeactivateUser(int, int, int) error { return nil }
|
||||||
|
|
||||||
|
func (f *fakePosService) LoginWithPin(int, int, string) (*models.PosSession, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePosService) ConfigidFor(int) int { return 0 }
|
||||||
|
|
||||||
func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) {
|
func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
219
middleware/posauth.go
Normal file
219
middleware/posauth.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/services"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authorisation for the POS terminal.
|
||||||
|
//
|
||||||
|
// Before this, the whole POS surface was open. A till named its own outlet on
|
||||||
|
// the wire and was believed, so `store_id=1185` in a URL was enough to read
|
||||||
|
// another tenant's catalogue or post bills into their books. There was no
|
||||||
|
// middleware in the codebase at all and the `JWT_SECRET_KEY` in the config was
|
||||||
|
// read and never used.
|
||||||
|
//
|
||||||
|
// The fix is two checks, in this order:
|
||||||
|
//
|
||||||
|
// 1. the caller holds a token this server signed, and
|
||||||
|
// 2. the outlet they are naming belongs to the tenant inside that token.
|
||||||
|
//
|
||||||
|
// The second is the one that matters. A valid token is not a licence to name
|
||||||
|
// any location — it is a licence to name *your* locations, and without the
|
||||||
|
// cross-check a real terminal at one shop could still read the shop next door.
|
||||||
|
|
||||||
|
// PosLocalsKey names where the verified claims are parked for handlers.
|
||||||
|
const PosLocalsKey = "posclaims"
|
||||||
|
|
||||||
|
// posAuthRequired reports whether a request without a valid token is refused.
|
||||||
|
//
|
||||||
|
// Defaults to OFF, and that is a deliberate, temporary choice rather than an
|
||||||
|
// oversight. Terminals are already in shops billing real customers against the
|
||||||
|
// unauthenticated endpoints; switching enforcement on at deploy would stop
|
||||||
|
// every one of them mid-trade. So the endpoint ships first, tills adopt it, and
|
||||||
|
// `POS_AUTH_REQUIRED=true` closes the door once the fleet is carrying tokens.
|
||||||
|
//
|
||||||
|
// While it is off a token is still *verified* when one is sent, and a request
|
||||||
|
// carrying a token for the wrong tenant is still refused — the flag only
|
||||||
|
// decides what happens to a request carrying none.
|
||||||
|
func posAuthRequired() bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(os.Getenv("POS_AUTH_REQUIRED")), "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosAuth verifies the session token and pins the request to its outlet.
|
||||||
|
func PosAuth(pos services.PosService) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
token := bearerToken(c)
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
if posAuthRequired() {
|
||||||
|
return posUnauthorized(c, "a session token is required; sign in at /pos/login")
|
||||||
|
}
|
||||||
|
// Legacy till. Allowed through un-pinned, which is exactly the state
|
||||||
|
// this middleware exists to end — see posAuthRequired.
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := utils.ParsePosToken(token, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
// Always refused, flag or no flag. A token that does not verify is
|
||||||
|
// a stronger signal than no token at all: nothing sends a broken
|
||||||
|
// one by accident.
|
||||||
|
return posUnauthorized(c, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The outlet named in the request, if it named one. Every POS route
|
||||||
|
// spells this differently — `store_id` on catalogue, `locationid` on
|
||||||
|
// sales, `location_id` on health — so all three are read rather than
|
||||||
|
// the caller being asked to change.
|
||||||
|
requested := requestedLocation(c)
|
||||||
|
|
||||||
|
if requested > 0 && requested != claims.Locationid {
|
||||||
|
// A different outlet than the token was issued for. Permitted only
|
||||||
|
// if the tenant genuinely owns it — a proprietor with six shops
|
||||||
|
// should be able to look at all six from one signed-in session.
|
||||||
|
allowed, err := pos.LocationAllowed(claims.Tenantid, requested)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
|
||||||
|
"code": http.StatusServiceUnavailable, "status": false,
|
||||||
|
"message": "could not verify outlet access",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"code": http.StatusForbidden, "status": false,
|
||||||
|
"message": "this session cannot reach outlet " + strconv.Itoa(requested),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Locals(PosLocalsKey, claims)
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearerToken reads the session out of the request.
|
||||||
|
//
|
||||||
|
// `Authorization: Bearer …` is the form to use. `X-Pos-Token` is accepted as
|
||||||
|
// well because some of the shop routers between a till and this server strip
|
||||||
|
// Authorization headers on plain HTTP, and a terminal that cannot authenticate
|
||||||
|
// is a shop that cannot trade.
|
||||||
|
func bearerToken(c *fiber.Ctx) string {
|
||||||
|
header := strings.TrimSpace(c.Get("Authorization"))
|
||||||
|
if header != "" {
|
||||||
|
if after, found := strings.CutPrefix(header, "Bearer "); found {
|
||||||
|
return strings.TrimSpace(after)
|
||||||
|
}
|
||||||
|
if !strings.Contains(header, " ") {
|
||||||
|
// Tolerates a bare token. Terminals in the field get this wrong and
|
||||||
|
// the alternative is a shop that cannot sell.
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(c.Get("X-Pos-Token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestedLocation reads whichever outlet parameter this route happens to use.
|
||||||
|
//
|
||||||
|
// The three spellings are a wart — `store_id`, `locationid` and `location_id`
|
||||||
|
// all mean the same thing across the POS routes. Normalising them is a breaking
|
||||||
|
// change for terminals already in the field, so this reads all three instead
|
||||||
|
// and leaves the naming alone.
|
||||||
|
//
|
||||||
|
// The body is searched as well as the query, and that is not an optional extra:
|
||||||
|
// the two routes that *write* — order and customer ingest — carry `store_id` in
|
||||||
|
// a JSON batch and never in the URL. Checking only the query string would leave
|
||||||
|
// the exact call that posts bills into another tenant's books unguarded, which
|
||||||
|
// is the hole this middleware exists to close.
|
||||||
|
func requestedLocation(c *fiber.Ctx) int {
|
||||||
|
for _, key := range []string{"store_id", "locationid", "location_id"} {
|
||||||
|
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
|
||||||
|
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bodyLocation(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bodyLocation pulls the outlet out of a JSON request body.
|
||||||
|
//
|
||||||
|
// Decoded into a loose map rather than the batch type on purpose. This runs
|
||||||
|
// before the handler and must not reject anything the handler would have
|
||||||
|
// accepted — a body that fails to parse here is left to the handler to refuse
|
||||||
|
// with its own message, and a batch shape that changes later must not silently
|
||||||
|
// stop being authorised.
|
||||||
|
//
|
||||||
|
// `c.Body()` returns the buffered bytes, so reading it here does not consume
|
||||||
|
// the stream the handler goes on to parse.
|
||||||
|
func bodyLocation(c *fiber.Ctx) int {
|
||||||
|
body := c.Body()
|
||||||
|
if len(body) == 0 || len(body) > 8<<20 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var probe struct {
|
||||||
|
Storeid json.RawMessage `json:"store_id"`
|
||||||
|
Locationid json.RawMessage `json:"location_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &probe); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range []json.RawMessage{probe.Storeid, probe.Locationid} {
|
||||||
|
if id := asLocationID(raw); id > 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// asLocationID reads an id that may have been sent as a number or as a string.
|
||||||
|
//
|
||||||
|
// The till sends `"store_id": "1135"` and the health payload sends
|
||||||
|
// `"location_id": "1135"`, both quoted, while other callers send it bare.
|
||||||
|
// Accepting only one shape would silently skip the check for the other — and a
|
||||||
|
// skipped check here reads exactly like a passed one.
|
||||||
|
func asLocationID(raw json.RawMessage) int {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var asString string
|
||||||
|
if err := json.Unmarshal(raw, &asString); err == nil {
|
||||||
|
if id, err := strconv.Atoi(strings.TrimSpace(asString)); err == nil && id > 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var asNumber int
|
||||||
|
if err := json.Unmarshal(raw, &asNumber); err == nil && asNumber > 0 {
|
||||||
|
return asNumber
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func posUnauthorized(c *fiber.Ctx, message string) error {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"code": http.StatusUnauthorized, "status": false, "message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosClaimsFrom returns the verified session on a request, if it carried one.
|
||||||
|
//
|
||||||
|
// The second return distinguishes "no token" from "a token claiming tenant 0",
|
||||||
|
// which a caller acting on the tenant id must not confuse.
|
||||||
|
func PosClaimsFrom(c *fiber.Ctx) (utils.PosClaims, bool) {
|
||||||
|
claims, ok := c.Locals(PosLocalsKey).(utils.PosClaims)
|
||||||
|
return claims, ok
|
||||||
|
}
|
||||||
151
middleware/posauth_test.go
Normal file
151
middleware/posauth_test.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The outlet a request names has to be found wherever the route happens to put
|
||||||
|
// it. These cover the extraction alone — it is the part that decides whether
|
||||||
|
// the authorisation check runs at all, and a miss here reads exactly like a
|
||||||
|
// pass.
|
||||||
|
|
||||||
|
func locationFor(t *testing.T, method, target, body string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
found := -1
|
||||||
|
app.All("/probe", func(c *fiber.Ctx) error {
|
||||||
|
found = requestedLocation(c)
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
var reader *strings.Reader
|
||||||
|
if body == "" {
|
||||||
|
reader = strings.NewReader("")
|
||||||
|
} else {
|
||||||
|
reader = strings.NewReader(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(method, target, reader)
|
||||||
|
if body != "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := app.Test(req); err != nil {
|
||||||
|
t.Fatalf("probing: %v", err)
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheOutletIsFoundUnderEveryNameTheRoutesUse(t *testing.T) {
|
||||||
|
// Three spellings for one thing across the POS routes. Missing any of them
|
||||||
|
// leaves that route unguarded.
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
target string
|
||||||
|
}{
|
||||||
|
{"catalogue says store_id", "/probe?store_id=1135"},
|
||||||
|
{"sales say locationid", "/probe?locationid=1135"},
|
||||||
|
{"health says location_id", "/probe?location_id=1135"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := locationFor(t, "GET", tc.target, ""); got != 1135 {
|
||||||
|
t.Fatalf("wanted outlet 1135, got %d", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two routes that *write* carry the outlet in a JSON batch and never in the
|
||||||
|
// URL. Checking only the query string would leave the exact call that posts
|
||||||
|
// bills into another tenant's books unguarded.
|
||||||
|
func TestTheOutletIsFoundInAnIngestBody(t *testing.T) {
|
||||||
|
body := `{"batch_id":"b1","terminal_id":"T5EDD","store_id":"1135","orders":[]}`
|
||||||
|
|
||||||
|
if got := locationFor(t, "POST", "/probe", body); got != 1135 {
|
||||||
|
t.Fatalf("wanted outlet 1135 from the batch body, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The till quotes its ids; other callers send them bare. Accepting only one
|
||||||
|
// shape silently skips the check for the other.
|
||||||
|
func TestAnOutletIsReadWhetherQuotedOrNot(t *testing.T) {
|
||||||
|
quoted := `{"store_id":"1135"}`
|
||||||
|
bare := `{"store_id":1135}`
|
||||||
|
|
||||||
|
if got := locationFor(t, "POST", "/probe", quoted); got != 1135 {
|
||||||
|
t.Fatalf("quoted store_id: wanted 1135, got %d", got)
|
||||||
|
}
|
||||||
|
if got := locationFor(t, "POST", "/probe", bare); got != 1135 {
|
||||||
|
t.Fatalf("bare store_id: wanted 1135, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHealthBodyNamesItsOutlet(t *testing.T) {
|
||||||
|
body := `{"terminal_id":"T5EDD","location_id":"1135","status":"online"}`
|
||||||
|
|
||||||
|
if got := locationFor(t, "POST", "/probe", body); got != 1135 {
|
||||||
|
t.Fatalf("wanted outlet 1135 from the health body, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request naming no outlet is not an error — /session names none — so it must
|
||||||
|
// come back as "nothing to check" rather than as outlet zero.
|
||||||
|
func TestARequestNamingNoOutletReportsNone(t *testing.T) {
|
||||||
|
if got := locationFor(t, "GET", "/probe", ""); got != 0 {
|
||||||
|
t.Fatalf("wanted 0 for a request naming no outlet, got %d", got)
|
||||||
|
}
|
||||||
|
if got := locationFor(t, "POST", "/probe", `{"batch_id":"b1"}`); got != 0 {
|
||||||
|
t.Fatalf("wanted 0 for a body naming no outlet, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A body this middleware cannot parse must not be treated as naming an outlet.
|
||||||
|
// The handler will refuse it on its own terms; guessing here would either
|
||||||
|
// reject a good request or wave a bad one through.
|
||||||
|
func TestAnUnparseableBodyNamesNoOutlet(t *testing.T) {
|
||||||
|
if got := locationFor(t, "POST", "/probe", `{not json at all`); got != 0 {
|
||||||
|
t.Fatalf("wanted 0 for an unparseable body, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABearerTokenIsReadInEveryFormTheFieldSends(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
var got string
|
||||||
|
app.Get("/probe", func(c *fiber.Ctx) error {
|
||||||
|
got = bearerToken(c)
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
value string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"the standard form", "Authorization", "Bearer abc.def", "abc.def"},
|
||||||
|
{"a bare token, which terminals send", "Authorization", "abc.def", "abc.def"},
|
||||||
|
{"the fallback header", "X-Pos-Token", "abc.def", "abc.def"},
|
||||||
|
{"a scheme we do not issue", "Authorization", "Basic dXNlcjpwdw==", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got = ""
|
||||||
|
req := httptest.NewRequest("GET", "/probe", nil)
|
||||||
|
req.Header.Set(tc.header, tc.value)
|
||||||
|
if _, err := app.Test(req); err != nil {
|
||||||
|
t.Fatalf("probing: %v", err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("wanted %q, got %q", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
268
models/pos.go
268
models/pos.go
@@ -1,5 +1,7 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// Wire format for the Nearle POS terminal.
|
// Wire format for the Nearle POS terminal.
|
||||||
//
|
//
|
||||||
// These types mirror what the till actually publishes, field for field. The
|
// These types mirror what the till actually publishes, field for field. The
|
||||||
@@ -210,3 +212,269 @@ type PosCatalogueResponse struct {
|
|||||||
Customers []PosCatalogueCustomer `json:"customers"`
|
Customers []PosCatalogueCustomer `json:"customers"`
|
||||||
Retiredids []string `json:"retired_product_ids"`
|
Retiredids []string `json:"retired_product_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Sign-in
|
||||||
|
//
|
||||||
|
// A terminal used to hold a store id typed into Settings and a password
|
||||||
|
// compiled into the app. That made the store id a *claim* rather than a fact:
|
||||||
|
// any till could name any outlet and be believed, and one leaked build opened
|
||||||
|
// every tenant on the platform.
|
||||||
|
//
|
||||||
|
// These types replace it with the account model the web console already uses.
|
||||||
|
// A person signs in with their own `app_users` credentials, and the outlet
|
||||||
|
// comes out of their record instead of going in from the wire.
|
||||||
|
|
||||||
|
// PosLoginRequest is what a till sends to sign in.
|
||||||
|
//
|
||||||
|
// Authname or Contactno, matching the web console's own login — a shop should
|
||||||
|
// not need a second set of credentials just because the screen is a till.
|
||||||
|
//
|
||||||
|
// Locationid is optional and only means anything for a user entitled to more
|
||||||
|
// than one outlet: it says which of theirs this terminal is standing in. It is
|
||||||
|
// checked against what they may reach, never trusted on its own.
|
||||||
|
type PosLoginRequest struct {
|
||||||
|
Authname string `json:"authname"`
|
||||||
|
Contactno string `json:"contactno"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Configid int `json:"configid"`
|
||||||
|
Locationid int `json:"location_id"`
|
||||||
|
|
||||||
|
// A PIN, for signing on at a terminal a supervisor has already opened. Only
|
||||||
|
// honoured by the PIN route, which requires an existing session — four
|
||||||
|
// digits is no barrier to an anonymous caller.
|
||||||
|
Pin string `json:"pin"`
|
||||||
|
|
||||||
|
// Which physical till is asking. Recorded on the session so a stolen token
|
||||||
|
// can be told apart from the terminal it was issued to.
|
||||||
|
Terminalid string `json:"terminal_id"`
|
||||||
|
Deviceid string `json:"device_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosLoginLocation is one outlet a signed-in user may bill for.
|
||||||
|
type PosLoginLocation struct {
|
||||||
|
Locationid int `json:"location_id"`
|
||||||
|
Locationname string `json:"location_name"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
City string `json:"city,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosSession is what a till holds for the rest of the trading day.
|
||||||
|
//
|
||||||
|
// Storeid is returned as a string because that is the shape the terminal's
|
||||||
|
// configuration already stores and sends — handing it back in the form it will
|
||||||
|
// be replayed in removes a conversion, and a conversion is where a store id
|
||||||
|
// gets mangled.
|
||||||
|
type PosSession struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Expiresat string `json:"expires_at"`
|
||||||
|
|
||||||
|
Userid int `json:"user_id"`
|
||||||
|
Fullname string `json:"full_name"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Roleid int `json:"role_id"`
|
||||||
|
|
||||||
|
// What the role is called, and the one thing the terminal actually branches
|
||||||
|
// on. Sent as a flag rather than leaving the till to map role ids itself:
|
||||||
|
// `app_roles` has six rows for four roles and most accounts carry an id
|
||||||
|
// absent from it, so any mapping written on the terminal would be wrong.
|
||||||
|
Role string `json:"role"`
|
||||||
|
Canmanagestaff bool `json:"can_manage_staff"`
|
||||||
|
|
||||||
|
// Which portal this account belongs to. Carried so a supervisor creating a
|
||||||
|
// cashier gives them the same configid — an account created under the wrong
|
||||||
|
// one cannot sign into the web console and is invisible to half the
|
||||||
|
// platform's queries. Not sent to the terminal: it has no use for it and it
|
||||||
|
// is one more number to get wrong.
|
||||||
|
Configid int `json:"-"`
|
||||||
|
|
||||||
|
Tenantid int `json:"tenant_id"`
|
||||||
|
Tenantname string `json:"tenant_name"`
|
||||||
|
|
||||||
|
Storeid string `json:"store_id"`
|
||||||
|
Locationid int `json:"location_id"`
|
||||||
|
Locationname string `json:"location_name"`
|
||||||
|
Gstin string `json:"gstin,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
|
||||||
|
// Every outlet this account may sign a terminal into. A single-outlet user
|
||||||
|
// gets a list of one, so the till has no special case: it shows a picker
|
||||||
|
// when there is a choice and skips it when there is not.
|
||||||
|
Locations []PosLoginLocation `json:"locations"`
|
||||||
|
|
||||||
|
// The people who may ring a bill at the chosen outlet.
|
||||||
|
//
|
||||||
|
// Sent with the session so a terminal is ready to trade the moment it signs
|
||||||
|
// in, rather than needing a second call before the first customer. May be
|
||||||
|
// empty — most tenants have no staff recorded yet — and the terminal has to
|
||||||
|
// cope with that rather than treat it as a failure.
|
||||||
|
Staff []PosStaffMember `json:"staff"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosStaffMember is one person who may ring a bill at an outlet.
|
||||||
|
//
|
||||||
|
// Distinct from the account that signs the *terminal* in. The sign-in says
|
||||||
|
// which shop this till belongs to; this says who is standing at it, and it is
|
||||||
|
// what gets stamped on a bill as `cashiername` and settled against at the end
|
||||||
|
// of a shift.
|
||||||
|
//
|
||||||
|
// The PIN travels in the clear, over TLS, and that is a considered choice
|
||||||
|
// rather than an oversight. A four-digit PIN is brute-forceable in microseconds
|
||||||
|
// whatever it is wrapped in, so hashing it here would buy the appearance of
|
||||||
|
// strength and not the substance. What it would cost is real: the terminal
|
||||||
|
// salts every PIN with its own random salt before storing it, so a hash
|
||||||
|
// computed here could never be verified there without inventing a shared
|
||||||
|
// scheme and keeping two codebases agreeing about it for ever.
|
||||||
|
//
|
||||||
|
// The honest framing is that a PIN is *shift attribution*, not a security
|
||||||
|
// boundary. The boundary is the session token — which is what stops a till
|
||||||
|
// reaching another tenant's books at all. The PIN decides which of the people
|
||||||
|
// already inside a shop gets credited with a sale, and the terminal still
|
||||||
|
// stores it hashed at rest.
|
||||||
|
type PosStaffMember struct {
|
||||||
|
Userid int `json:"user_id"`
|
||||||
|
Fullname string `json:"full_name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Pin string `json:"pin,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosStaffResponse answers a request for an outlet's people.
|
||||||
|
type PosStaffResponse struct {
|
||||||
|
Locationid int `json:"location_id"`
|
||||||
|
Staff []PosStaffMember `json:"staff"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ POS staff roles
|
||||||
|
//
|
||||||
|
// `app_roles` is keyed by roleid and carries a configid, so the same name
|
||||||
|
// appears more than once — Admin is both 3 and 5, Manager both 4 and 6, one per
|
||||||
|
// portal. These two are deliberately not per-portal: a till is a till whichever
|
||||||
|
// tenant owns it, and a role that had to be duplicated per config would be one
|
||||||
|
// more thing to remember when a tenant is onboarded.
|
||||||
|
//
|
||||||
|
// The ids are fixed rather than allocated, because they are referenced from the
|
||||||
|
// terminal and from this source. `app_roles.roleid` has no sequence and no
|
||||||
|
// default — every id in that table was assigned by hand — so nothing is being
|
||||||
|
// worked around here.
|
||||||
|
const (
|
||||||
|
// PosRoleSupervisor runs the terminal: settings, imports, price overrides,
|
||||||
|
// voids, and creating the people below.
|
||||||
|
PosRoleSupervisor = 7
|
||||||
|
|
||||||
|
// PosRoleCashier bills, and nothing else.
|
||||||
|
PosRoleCashier = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
// PosRoleName maps a role id to what a person calls it.
|
||||||
|
func PosRoleName(roleID int) string {
|
||||||
|
switch roleID {
|
||||||
|
case PosRoleSupervisor:
|
||||||
|
return "Supervisor"
|
||||||
|
case PosRoleCashier:
|
||||||
|
return "Cashier"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosRoleFromName reads the role off a request.
|
||||||
|
//
|
||||||
|
// Accepts the name rather than the number, so a caller never has to hardcode 7
|
||||||
|
// or 8 — and returns 0 for anything unrecognised, which every caller treats as
|
||||||
|
// a refusal rather than as a default.
|
||||||
|
func PosRoleFromName(name string) int {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||||
|
case "supervisor":
|
||||||
|
return PosRoleSupervisor
|
||||||
|
case "cashier":
|
||||||
|
return PosRoleCashier
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosRoleEligible reports whether a role may open a till at all.
|
||||||
|
//
|
||||||
|
// The terminal and the Nearle Daily application share one `app_users` table,
|
||||||
|
// and that is the only thing they share. An account belongs to one product or
|
||||||
|
// the other and never to both: a person who administers a shop from a browser
|
||||||
|
// does not thereby get a cash drawer, and a cashier does not thereby get the
|
||||||
|
// back office.
|
||||||
|
//
|
||||||
|
// Eligibility is therefore granted explicitly — by provisioning a Supervisor or
|
||||||
|
// a Cashier from the console — and is never inherited from a back-office role.
|
||||||
|
// Anything else is refused at sign-in, including roleid 0, which is not a role
|
||||||
|
// but the absence of one.
|
||||||
|
func PosRoleEligible(roleID int) bool {
|
||||||
|
return roleID == PosRoleSupervisor || roleID == PosRoleCashier
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosRoleCanManageStaff reports whether a role may create and edit till users.
|
||||||
|
//
|
||||||
|
// Supervisors, and nobody else.
|
||||||
|
//
|
||||||
|
// This used to include the back office's own roles 1 to 6, on the reasoning
|
||||||
|
// that somebody who can already administer a shop from a browser is not made
|
||||||
|
// less privileged by standing at the counter. That was wrong, and live data
|
||||||
|
// showed how wrong: it handed till-supervisor powers to 68 accounts, 59 of them
|
||||||
|
// Nearle Daily Super admins, not one of whom is the administrator of anybody's
|
||||||
|
// POS. The actual shop accounts carry roleid 0 and were refused.
|
||||||
|
//
|
||||||
|
// The back office reaches the till by *provisioning* a supervisor from the
|
||||||
|
// console, not by becoming one at the counter.
|
||||||
|
func PosRoleCanManageStaff(roleID int) bool {
|
||||||
|
return roleID == PosRoleSupervisor
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosUser is a person who signs in at a till.
|
||||||
|
type PosUser struct {
|
||||||
|
Userid int `json:"user_id"`
|
||||||
|
Fullname string `json:"full_name"`
|
||||||
|
Firstname string `json:"first_name,omitempty"`
|
||||||
|
Lastname string `json:"last_name,omitempty"`
|
||||||
|
Authname string `json:"authname,omitempty"`
|
||||||
|
Contactno string `json:"contactno,omitempty"`
|
||||||
|
Roleid int `json:"role_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Pin string `json:"pin,omitempty"`
|
||||||
|
Haspassword bool `json:"has_password"`
|
||||||
|
|
||||||
|
// The password, returned only in the answer to a creation or a reset and
|
||||||
|
// never by a listing. An admin who loses it reissues rather than looks it
|
||||||
|
// up — the right shape even while the column behind it is plaintext.
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
Locationid int `json:"location_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosUserRequest creates or edits a till user.
|
||||||
|
//
|
||||||
|
// Note what is absent: tenant and location. Both come from the caller's own
|
||||||
|
// session token. A supervisor creating staff can only ever create them at their
|
||||||
|
// own outlet, and no field in this struct can say otherwise — which is the same
|
||||||
|
// inversion that stopped a till naming its own shop.
|
||||||
|
type PosUserRequest struct {
|
||||||
|
Userid int `json:"user_id"`
|
||||||
|
Fullname string `json:"full_name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Pin string `json:"pin"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Authname string `json:"authname"`
|
||||||
|
Contactno string `json:"contactno"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosUserWebRequest is a staff change made from the web console.
|
||||||
|
//
|
||||||
|
// Identical to [PosUserRequest] but for the two fields a terminal never needs
|
||||||
|
// to send: the console has no session token, so it has to name the outlet it is
|
||||||
|
// working on. That is the one real difference between the two doors into this,
|
||||||
|
// and it is also the weaker one — the till's outlet is proved by a signature,
|
||||||
|
// while this is asserted. The handler checks the outlet belongs to the tenant
|
||||||
|
// before writing anything, which is as far as it can go without the console
|
||||||
|
// holding a session of its own.
|
||||||
|
type PosUserWebRequest struct {
|
||||||
|
PosUserRequest
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ type Tenantpricing struct {
|
|||||||
|
|
||||||
type StaffInfo struct {
|
type StaffInfo struct {
|
||||||
Userid int `json:"userid"`
|
Userid int `json:"userid"`
|
||||||
|
// What the role is called, so a console does not have to map ids itself.
|
||||||
|
// `app_roles` holds six rows for four back-office roles and most accounts
|
||||||
|
// carry an id absent from it, so any mapping written client-side is wrong.
|
||||||
|
Rolename string `json:"rolename"`
|
||||||
Authname string `json:"authname"`
|
Authname string `json:"authname"`
|
||||||
Configid int `json:"configid"`
|
Configid int `json:"configid"`
|
||||||
Authmode int `json:"authmode"`
|
Authmode int `json:"authmode"`
|
||||||
|
|||||||
428
repositories/posAuthRepository.go
Normal file
428
repositories/posAuthRepository.go
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sign-in for the POS terminal.
|
||||||
|
//
|
||||||
|
// Deliberately reads the same `app_users` rows the web console authenticates
|
||||||
|
// against rather than introducing a terminal-specific credential table. A shop
|
||||||
|
// manager who can sign into the back office should be able to open the till
|
||||||
|
// with the same details, and one account store means deactivating a leaver
|
||||||
|
// closes both doors at once instead of one and a half.
|
||||||
|
//
|
||||||
|
// Kept in its own file because the rest of posRepository is about moving bills
|
||||||
|
// and stock, and mixing authorisation into that made the one thing nobody
|
||||||
|
// should have to hunt for the hardest thing to find.
|
||||||
|
|
||||||
|
// posLoginRow is the credential check's raw answer.
|
||||||
|
type posLoginRow struct {
|
||||||
|
Userid int
|
||||||
|
Password string
|
||||||
|
Status string
|
||||||
|
Roleid int
|
||||||
|
Configid int
|
||||||
|
Tenantid int
|
||||||
|
Locationid int
|
||||||
|
Firstname string
|
||||||
|
Lastname string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosLogin authenticates a user and returns the session they are entitled to.
|
||||||
|
//
|
||||||
|
// The outlet is resolved here, from the user's own row and the tenant's list of
|
||||||
|
// locations — never from anything the caller sent. That inversion is the whole
|
||||||
|
// point of the endpoint.
|
||||||
|
func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession, error) {
|
||||||
|
field, value := "authname", strings.TrimSpace(req.Authname)
|
||||||
|
if value == "" {
|
||||||
|
field, value = "contactno", strings.TrimSpace(req.Contactno)
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
return nil, fmt.Errorf("an email or mobile number is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.posLoginCandidates(field, value, req.Configid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// One message for "no such account" and for "wrong password", on purpose.
|
||||||
|
// Distinguishing them turns the login into a directory of who banks here.
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, errPosLoginRejected
|
||||||
|
}
|
||||||
|
|
||||||
|
// `authname` is not unique in this schema — live data has the same address
|
||||||
|
// twice under one configid — so more than one row can come back. Resolving
|
||||||
|
// that by taking the first would let the account a person *meant* be
|
||||||
|
// shadowed by a stranger's, and on a POS that means billing into the wrong
|
||||||
|
// tenant's books. Refused instead, with the fix the caller can act on.
|
||||||
|
if len(rows) > 1 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"more than one account uses these sign-in details; ask your administrator for the configid and send it with the login")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inactive accounts never reach here — posLoginCandidates excludes them, so
|
||||||
|
// that a deactivated duplicate cannot make a live login ambiguous.
|
||||||
|
row := rows[0]
|
||||||
|
|
||||||
|
// Matches the web console's plaintext comparison, which is what the stored
|
||||||
|
// column holds today. Constant-time so this endpoint at least does not add
|
||||||
|
// a timing oracle on top.
|
||||||
|
//
|
||||||
|
// TODO: the password column is plaintext across the whole platform. Hashing
|
||||||
|
// it is a migration touching every login path, not something this endpoint
|
||||||
|
// can fix alone — but a POS token minted off a plaintext password is only
|
||||||
|
// ever as good as that column.
|
||||||
|
if strings.TrimSpace(row.Password) == "" {
|
||||||
|
return nil, fmt.Errorf("this account has no password set; set one in the web console first")
|
||||||
|
}
|
||||||
|
if !constantTimeEqual(row.Password, req.Password) {
|
||||||
|
return nil, errPosLoginRejected
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.sessionFor(row, req.Locationid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionFor turns an authenticated account into the session it is entitled to.
|
||||||
|
//
|
||||||
|
// Shared by both ways in — an email and password, or a PIN at an already-open
|
||||||
|
// terminal. Extracted rather than duplicated because everything after the
|
||||||
|
// credential check is authorisation, and two copies of an authorisation rule
|
||||||
|
// is one copy too many.
|
||||||
|
//
|
||||||
|
// [requestedLocation] is optional and only means anything for an account that
|
||||||
|
// reaches more than one outlet. It is checked against that set, never trusted
|
||||||
|
// on its own.
|
||||||
|
func (r *posRepository) sessionFor(row posLoginRow, requestedLocation int) (*models.PosSession, error) {
|
||||||
|
// The till is not the back office, and one account is never both. An
|
||||||
|
// account reaches a terminal only by having been provisioned for one —
|
||||||
|
// Supervisor or Cashier, created from the console — and never by carrying a
|
||||||
|
// Nearle Daily role that happens to sound senior.
|
||||||
|
//
|
||||||
|
// Checked here rather than in PosLogin so that the PIN route is covered by
|
||||||
|
// the same line. Both ways in build their session through this function, and
|
||||||
|
// a gate on only one of them would be a gate on neither.
|
||||||
|
if !models.PosRoleEligible(row.Roleid) {
|
||||||
|
return nil, errPosRoleIneligible
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Tenantid <= 0 {
|
||||||
|
return nil, fmt.Errorf("this account is not attached to a tenant and cannot open a till")
|
||||||
|
}
|
||||||
|
|
||||||
|
locations, err := r.posLoginLocations(row.Tenantid, row.Locationid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(locations) == 0 {
|
||||||
|
return nil, fmt.Errorf("no active outlet is registered for this account")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which outlet this terminal is standing in. A request may ask for one, but
|
||||||
|
// only from the set the account already reaches.
|
||||||
|
chosen := locations[0]
|
||||||
|
if requestedLocation > 0 {
|
||||||
|
match := false
|
||||||
|
for _, loc := range locations {
|
||||||
|
if loc.Locationid == requestedLocation {
|
||||||
|
chosen, match = loc, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
return nil, fmt.Errorf("this account cannot open a till at outlet %d", requestedLocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &models.PosSession{
|
||||||
|
Userid: row.Userid,
|
||||||
|
Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname),
|
||||||
|
Email: row.Email,
|
||||||
|
Roleid: row.Roleid,
|
||||||
|
Role: posRoleLabel(row.Roleid),
|
||||||
|
Configid: row.Configid,
|
||||||
|
Canmanagestaff: models.PosRoleCanManageStaff(row.Roleid),
|
||||||
|
Tenantid: row.Tenantid,
|
||||||
|
Storeid: fmt.Sprintf("%d", chosen.Locationid),
|
||||||
|
Locationid: chosen.Locationid,
|
||||||
|
Locationname: chosen.Locationname,
|
||||||
|
Address: chosen.Address,
|
||||||
|
Locations: locations,
|
||||||
|
}
|
||||||
|
|
||||||
|
r.decoratePosSession(session)
|
||||||
|
|
||||||
|
// Staff come down with the session so a till is ready to trade the moment
|
||||||
|
// it signs in. A failure here is not a failed sign-in: a shop with no staff
|
||||||
|
// recorded — which is almost all of them today — must still be able to open
|
||||||
|
// its terminal.
|
||||||
|
if staff, err := r.PosStaff(session.Tenantid, session.Locationid); err == nil {
|
||||||
|
session.Staff = staff
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// posRoleLabel names a role for the terminal.
|
||||||
|
//
|
||||||
|
// Prefers the two POS roles this codebase defines, then falls back to whatever
|
||||||
|
// `app_roles` calls it — which is blank for a great many accounts, because most
|
||||||
|
// carry a roleid that is not in that table at all.
|
||||||
|
func posRoleLabel(roleID int) string {
|
||||||
|
if name := models.PosRoleName(roleID); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
switch roleID {
|
||||||
|
case 1:
|
||||||
|
return "Super admin"
|
||||||
|
case 2:
|
||||||
|
return "Operations"
|
||||||
|
case 3, 5:
|
||||||
|
return "Admin"
|
||||||
|
case 4, 6:
|
||||||
|
return "Manager"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// posLoginCandidates finds the accounts matching a set of sign-in details.
|
||||||
|
//
|
||||||
|
// Returns a list rather than a row because `app_users` does not constrain
|
||||||
|
// `authname` to be unique — not globally and not per configid. The caller
|
||||||
|
// decides what an ambiguous match means; silently picking one here would bury
|
||||||
|
// the decision in a LIMIT 1.
|
||||||
|
//
|
||||||
|
// The configid handling is the part worth explaining. The web console asks for
|
||||||
|
// it because the browser knows which tenant portal it is on. A till does not:
|
||||||
|
// somebody is standing at a counter typing an email and a password, and
|
||||||
|
// demanding a number they have never seen would make the login unusable. So it
|
||||||
|
// is honoured when sent and inferred when not — and inference that finds more
|
||||||
|
// than one candidate is reported, never guessed.
|
||||||
|
func (r *posRepository) posLoginCandidates(field, value string, configID int) ([]posLoginRow, error) {
|
||||||
|
rows := make([]posLoginRow, 0, 2)
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT userid, COALESCE(password, '') AS password, COALESCE(status, '') AS status,
|
||||||
|
COALESCE(roleid, 0) AS roleid, COALESCE(configid, 0) AS configid,
|
||||||
|
COALESCE(tenantid, 0) AS tenantid, COALESCE(locationid, 0) AS locationid,
|
||||||
|
COALESCE(firstname, '') AS firstname, COALESCE(lastname, '') AS lastname,
|
||||||
|
COALESCE(email, '') AS email
|
||||||
|
FROM app_users
|
||||||
|
WHERE LOWER(TRIM(%s)) = LOWER(TRIM(?))`, field)
|
||||||
|
params := []interface{}{value}
|
||||||
|
|
||||||
|
if configID > 0 {
|
||||||
|
query += ` AND configid = ?`
|
||||||
|
params = append(params, configID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inactive accounts are excluded from the match rather than matched and
|
||||||
|
// then refused. A deactivated duplicate would otherwise make a working
|
||||||
|
// login ambiguous, which turns "this person left" into "nobody can open
|
||||||
|
// the till".
|
||||||
|
query += ` AND LOWER(COALESCE(status, 'active')) <> 'inactive' ORDER BY userid`
|
||||||
|
|
||||||
|
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// posLoginLocations lists the outlets an account may open a till at.
|
||||||
|
//
|
||||||
|
// A user pinned to one location gets that one alone; a tenant-level account
|
||||||
|
// with locationid 0 — a proprietor with several shops — gets all of the
|
||||||
|
// tenant's active outlets and picks at sign-in.
|
||||||
|
//
|
||||||
|
// Inactive outlets are excluded rather than listed and disabled: a till cannot
|
||||||
|
// usefully trade at a closed shop, and offering it is an invitation to a
|
||||||
|
// support call.
|
||||||
|
func (r *posRepository) posLoginLocations(tenantID, pinned int) ([]models.PosLoginLocation, error) {
|
||||||
|
rows := make([]models.PosLoginLocation, 0)
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT locationid,
|
||||||
|
COALESCE(locationname, '') AS locationname,
|
||||||
|
COALESCE(address, '') AS address,
|
||||||
|
COALESCE(city, '') AS city,
|
||||||
|
COALESCE(status, '') AS status
|
||||||
|
FROM tenantlocations
|
||||||
|
WHERE tenantid = ? AND LOWER(COALESCE(status, 'active')) <> 'inactive'`
|
||||||
|
params := []interface{}{tenantID}
|
||||||
|
|
||||||
|
if pinned > 0 {
|
||||||
|
query += ` AND locationid = ?`
|
||||||
|
params = append(params, pinned)
|
||||||
|
}
|
||||||
|
query += ` ORDER BY locationid`
|
||||||
|
|
||||||
|
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decoratePosSession fills in what a receipt needs.
|
||||||
|
//
|
||||||
|
// The store name, GSTIN and address printed on a bill are a legal requirement
|
||||||
|
// on a GST invoice, and the till had them as compile-time constants. Sending
|
||||||
|
// them down with the session means a shop that corrects its GSTIN in the back
|
||||||
|
// office sees the correction on its next receipt rather than at the next
|
||||||
|
// rebuild.
|
||||||
|
//
|
||||||
|
// Failures here are swallowed: a missing tenant name is a cosmetic problem, and
|
||||||
|
// refusing a sign-in over it would close a shop.
|
||||||
|
func (r *posRepository) decoratePosSession(session *models.PosSession) {
|
||||||
|
var tenant struct {
|
||||||
|
Tenantname string
|
||||||
|
Gstin string
|
||||||
|
Contactno string
|
||||||
|
Address string
|
||||||
|
}
|
||||||
|
|
||||||
|
// `registrationno` is where this schema keeps the GST number — there is no
|
||||||
|
// `gstin` column. Aliased rather than renamed through the stack so the till
|
||||||
|
// receives it under the name it prints on a receipt.
|
||||||
|
err := r.db.Raw(`
|
||||||
|
SELECT COALESCE(tenantname, '') AS tenantname,
|
||||||
|
COALESCE(registrationno, '') AS gstin,
|
||||||
|
COALESCE(primarycontact, '') AS contactno,
|
||||||
|
COALESCE(address, '') AS address
|
||||||
|
FROM tenants WHERE tenantid = ? LIMIT 1`, session.Tenantid).Scan(&tenant).Error
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Tenantname = tenant.Tenantname
|
||||||
|
session.Gstin = tenant.Gstin
|
||||||
|
session.Phone = tenant.Contactno
|
||||||
|
|
||||||
|
// The outlet's own address wins — a chain's receipts must name the shop the
|
||||||
|
// customer is standing in, not head office. The tenant address is only a
|
||||||
|
// fallback for an outlet that has none recorded.
|
||||||
|
if strings.TrimSpace(session.Address) == "" {
|
||||||
|
session.Address = tenant.Address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosLocationAllowed reports whether a tenant owns an outlet.
|
||||||
|
//
|
||||||
|
// The check the whole session model rests on. Everything a terminal asks for
|
||||||
|
// names a location, and this is what stops a valid token for one shop being
|
||||||
|
// replayed against another.
|
||||||
|
func (r *posRepository) PosLocationAllowed(tenantID, locationID int) (bool, error) {
|
||||||
|
if tenantID <= 0 || locationID <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := r.db.Raw(
|
||||||
|
`SELECT COUNT(1) FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
|
||||||
|
tenantID, locationID,
|
||||||
|
).Scan(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errPosLoginRejected is the single answer to a bad email and a bad password.
|
||||||
|
var errPosLoginRejected = fmt.Errorf("those sign-in details were not recognised")
|
||||||
|
|
||||||
|
// errPosRoleIneligible is the answer to a correct credential on an account that
|
||||||
|
// is not a till account.
|
||||||
|
//
|
||||||
|
// Deliberately specific, where a bad password is deliberately vague. By the
|
||||||
|
// time this fires the caller has already proved the credential, so naming the
|
||||||
|
// reason leaks nothing they did not just demonstrate — and the vague answer
|
||||||
|
// would send a shop owner hunting for a password that was never wrong. It
|
||||||
|
// names the fix, because the fix is somebody else's screen.
|
||||||
|
var errPosRoleIneligible = fmt.Errorf(
|
||||||
|
"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")
|
||||||
|
|
||||||
|
// PosLoginRejected reports whether an error is a failed credential check, so
|
||||||
|
// the controller can answer 401 for those and 500 for a database fault without
|
||||||
|
// matching on message text.
|
||||||
|
func PosLoginRejected(err error) bool { return err == errPosLoginRejected }
|
||||||
|
|
||||||
|
// constantTimeEqual compares two secrets without leaking their contents through
|
||||||
|
// how long it took.
|
||||||
|
//
|
||||||
|
// Length is compared first and is deliberately allowed to leak — a password's
|
||||||
|
// length is not the secret, and hashing to a fixed width just to hide it would
|
||||||
|
// be more machinery than the exposure justifies.
|
||||||
|
func constantTimeEqual(a, b string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var diff byte
|
||||||
|
for i := 0; i < len(a); i++ {
|
||||||
|
diff |= a[i] ^ b[i]
|
||||||
|
}
|
||||||
|
return diff == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosStaff lists the people who may ring a bill at an outlet.
|
||||||
|
//
|
||||||
|
// Two sources, unioned, because the schema has two and neither is complete.
|
||||||
|
// `tenantstaffs` is the table built for this and holds 12 rows on the entire
|
||||||
|
// platform; `app_users.locationid` is where staff actually ended up. Reading
|
||||||
|
// only the purpose-built table would return nothing for almost every shop, and
|
||||||
|
// reading only `app_users` would miss anyone assigned through the back office's
|
||||||
|
// staff screen. So both.
|
||||||
|
//
|
||||||
|
// Only people with a PIN come back. A row with `pin = 0` cannot ring anything —
|
||||||
|
// offering it to the till would put a name on screen that no one can sign in
|
||||||
|
// as, which reads as a broken terminal rather than as an unfinished setup.
|
||||||
|
func (r *posRepository) PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error) {
|
||||||
|
rows := make([]models.PosStaffMember, 0)
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT DISTINCT
|
||||||
|
a.userid,
|
||||||
|
TRIM(CONCAT(COALESCE(a.firstname,''), ' ', COALESCE(a.lastname,''))) AS fullname,
|
||||||
|
COALESCE(r.rolename, '') AS role,
|
||||||
|
CAST(a.pin AS TEXT) AS pin,
|
||||||
|
COALESCE(a.status, '') AS status
|
||||||
|
FROM app_users a
|
||||||
|
LEFT JOIN app_roles r ON r.roleid = a.roleid
|
||||||
|
WHERE a.tenantid = ?
|
||||||
|
AND COALESCE(a.pin, 0) > 0
|
||||||
|
AND LOWER(COALESCE(a.status, 'active')) <> 'inactive'
|
||||||
|
AND (
|
||||||
|
a.locationid = ?
|
||||||
|
OR EXISTS (SELECT 1 FROM tenantstaffs s
|
||||||
|
WHERE s.userid = a.userid
|
||||||
|
AND s.tenantid = a.tenantid
|
||||||
|
AND s.locationid = ?
|
||||||
|
AND LOWER(COALESCE(s.status, 'active')) <> 'inactive')
|
||||||
|
)
|
||||||
|
ORDER BY fullname`
|
||||||
|
|
||||||
|
if err := r.db.Raw(query, tenantID, locationID, locationID).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A PIN shared by two people at one outlet would make the till attribute a
|
||||||
|
// bill to whichever row it happened to check first — so the second one is
|
||||||
|
// dropped rather than sent. Live data has 1234 on eleven accounts and 1111
|
||||||
|
// on nine, so this is not hypothetical.
|
||||||
|
seen := make(map[string]bool, len(rows))
|
||||||
|
unique := rows[:0]
|
||||||
|
for _, row := range rows {
|
||||||
|
if seen[row.Pin] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[row.Pin] = true
|
||||||
|
unique = append(unique, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return unique, nil
|
||||||
|
}
|
||||||
@@ -38,6 +38,22 @@ type PosRepository interface {
|
|||||||
IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error)
|
IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error)
|
||||||
Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error)
|
Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error)
|
||||||
|
|
||||||
|
// Sign-in. The outlet a terminal bills for is decided here, from the user's
|
||||||
|
// own record, rather than being named by the till and believed.
|
||||||
|
PosLogin(req models.PosLoginRequest) (*models.PosSession, error)
|
||||||
|
PosLocationAllowed(tenantID, locationID int) (bool, error)
|
||||||
|
PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error)
|
||||||
|
|
||||||
|
// Till staff, managed by the shop. Tenant and location are always the
|
||||||
|
// caller's own, taken from their session token — no argument here can name
|
||||||
|
// somebody else's outlet.
|
||||||
|
CreatePosUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error)
|
||||||
|
UpdatePosUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error)
|
||||||
|
ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error)
|
||||||
|
DeactivatePosUser(tenantID, locationID, userID int) error
|
||||||
|
PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error)
|
||||||
|
PosConfigidFor(tenantID int) int
|
||||||
|
|
||||||
// Reading counter sales back out. Without these a committed bill is
|
// Reading counter sales back out. Without these a committed bill is
|
||||||
// unreachable from every screen in the product.
|
// unreachable from every screen in the product.
|
||||||
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
|
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
|
||||||
@@ -112,7 +128,7 @@ func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck
|
|||||||
ack.Reject("", "order is missing its id")
|
ack.Reject("", "order is missing its id")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if reason := r.importPosOrder(ctx, products, batch.Batchid, order); reason != "" {
|
if reason := r.importPosOrder(ctx, products, batch.Batchid, batch.Terminalid, order); reason != "" {
|
||||||
ack.Reject(order.Id, reason)
|
ack.Reject(order.Id, reason)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -132,10 +148,15 @@ func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck
|
|||||||
// in opposite directions and both matter: the bill is its own kind of document
|
// in opposite directions and both matter: the bill is its own kind of document
|
||||||
// and deserves its own table, but stock is one number per shelf and must not be
|
// and deserves its own table, but stock is one number per shelf and must not be
|
||||||
// tracked twice.
|
// tracked twice.
|
||||||
|
// batchTerminal is the terminal the whole batch came from, used when a bill
|
||||||
|
// does not name one itself. Over MQTT the consumer fills it in from the topic;
|
||||||
|
// over HTTP the terminal sends it once at the top of the batch rather than
|
||||||
|
// repeating it on every bill.
|
||||||
func (r *posRepository) importPosOrder(
|
func (r *posRepository) importPosOrder(
|
||||||
ctx *offlineLocationContext,
|
ctx *offlineLocationContext,
|
||||||
products map[int]offlineProduct,
|
products map[int]offlineProduct,
|
||||||
batchID string,
|
batchID string,
|
||||||
|
batchTerminal string,
|
||||||
order models.PosOrder,
|
order models.PosOrder,
|
||||||
) string {
|
) string {
|
||||||
if len(order.Items) == 0 {
|
if len(order.Items) == 0 {
|
||||||
@@ -302,7 +323,11 @@ func (r *posRepository) importPosOrder(
|
|||||||
Invoicenumber: order.Invoicenumber,
|
Invoicenumber: order.Invoicenumber,
|
||||||
Tenantid: ctx.Tenantid,
|
Tenantid: ctx.Tenantid,
|
||||||
Locationid: ctx.Locationid,
|
Locationid: ctx.Locationid,
|
||||||
Terminalid: order.Terminalid,
|
// The bill's own terminal wins; the batch's is the fallback. Without
|
||||||
|
// this the column was empty on every bill that arrived over HTTP —
|
||||||
|
// the invoice number carried the code and the column did not, so
|
||||||
|
// per-terminal reconciliation had nothing to group on.
|
||||||
|
Terminalid: posTerminalFor(order.Terminalid, batchTerminal),
|
||||||
Cashiername: order.Cashier,
|
Cashiername: order.Cashier,
|
||||||
Customerid: customerID,
|
Customerid: customerID,
|
||||||
Customermobile: posCustomerMobile(order),
|
Customermobile: posCustomerMobile(order),
|
||||||
@@ -372,6 +397,18 @@ func posJSON(v any) string {
|
|||||||
return string(body)
|
return string(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// posTerminalFor picks which terminal code to file a bill under.
|
||||||
|
//
|
||||||
|
// Trimmed before the emptiness test: a terminal sending `" "` is saying nothing,
|
||||||
|
// and treating that as a real code would file bills under a blank that looks
|
||||||
|
// identical to the missing value this exists to fix.
|
||||||
|
func posTerminalFor(orderTerminal, batchTerminal string) string {
|
||||||
|
if t := strings.TrimSpace(orderTerminal); t != "" {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(batchTerminal)
|
||||||
|
}
|
||||||
|
|
||||||
func posCustomerName(order models.PosOrder) string {
|
func posCustomerName(order models.PosOrder) string {
|
||||||
if order.Customer == nil {
|
if order.Customer == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -391,6 +428,20 @@ func posCustomerMobile(order models.PosOrder) string {
|
|||||||
// The terminal sends ISO-8601. A blank one falls back to now; an unparseable
|
// The terminal sends ISO-8601. A blank one falls back to now; an unparseable
|
||||||
// one is refused, because importing a sale under the wrong date corrupts every
|
// one is refused, because importing a sale under the wrong date corrupts every
|
||||||
// daily revenue figure that reads it.
|
// daily revenue figure that reads it.
|
||||||
|
// parsePosSaleDate reads the moment a bill was rung.
|
||||||
|
//
|
||||||
|
// The order of these layouts is load-bearing, and the two zoned ones must stay
|
||||||
|
// first. A terminal that sends its offset — `2026-08-05T00:30:00+05:30` — gets
|
||||||
|
// both readings right: the instant is correct, and Format("2006-01-02") still
|
||||||
|
// yields the till's own trading day rather than UTC's.
|
||||||
|
//
|
||||||
|
// The two bare layouts exist for terminals built before the offset was added,
|
||||||
|
// which are still in the field. `time.Parse` fills an absent zone with UTC, so
|
||||||
|
// those bills record an instant wrong by the offset — a Coimbatore wall clock
|
||||||
|
// read as though it were London. That is not recoverable here: nothing in the
|
||||||
|
// payload says which zone it came from. Their business date is still right,
|
||||||
|
// which is why the daily figures held up while billedat did not, and why these
|
||||||
|
// are tolerated rather than refused.
|
||||||
func parsePosSaleDate(raw string) (time.Time, error) {
|
func parsePosSaleDate(raw string) (time.Time, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -160,3 +160,94 @@ func TestAnOutletCannotReplayAnotherOutletsRevision(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The bug this covers reached production and stayed invisible for a day.
|
||||||
|
//
|
||||||
|
// The MQTT consumer backfills a missing terminal code from the topic, but it
|
||||||
|
// wrote it onto the *batch* while the row was built from the *order*, so the
|
||||||
|
// two never met. Bills arriving over HTTP had no topic to fall back on at all.
|
||||||
|
// The result: 16 of 17 live bills carried an empty terminalid while their own
|
||||||
|
// invoice numbers read INV-2608-T5EDD-000NN, and `byterminal` on the sales
|
||||||
|
// summary grouped almost everything under "".
|
||||||
|
func TestABillTakesItsTerminalFromTheBatchWhenItNamesNone(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
orderTerminal string
|
||||||
|
batchTerminal string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"bill names its own", "T5EDD", "TOTHER", "T5EDD"},
|
||||||
|
{"bill is silent, batch knows", "", "T5EDD", "T5EDD"},
|
||||||
|
{"neither knows", "", "", ""},
|
||||||
|
|
||||||
|
// Whitespace is not a terminal code. Treating it as one would file
|
||||||
|
// bills under a blank that reads identically to the missing value
|
||||||
|
// this fallback exists to prevent.
|
||||||
|
{"bill sends whitespace", " ", "T5EDD", "T5EDD"},
|
||||||
|
{"batch sends whitespace", "", " ", ""},
|
||||||
|
{"codes are trimmed", " T5EDD ", "", "T5EDD"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := posTerminalFor(c.orderTerminal, c.batchTerminal); got != c.want {
|
||||||
|
t.Errorf("posTerminalFor(%q, %q) = %q, want %q",
|
||||||
|
c.orderTerminal, c.batchTerminal, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// billedat and businessdate are derived from the same parsed value and pull in
|
||||||
|
// opposite directions, so they are tested together.
|
||||||
|
//
|
||||||
|
// Live bill INV-2608-T5EDD-00116 carried billedat 2026-08-05T12:49:28Z beside
|
||||||
|
// receivedat 2026-08-05T07:19:28Z — the sale appearing to happen five and a
|
||||||
|
// half hours after it was received. The till was sending a naive local
|
||||||
|
// timestamp and time.Parse fills that silence with UTC, so a Coimbatore wall
|
||||||
|
// clock was recorded as though read in London.
|
||||||
|
//
|
||||||
|
// The daily figures survived it by luck: businessdate comes off the wall clock
|
||||||
|
// either way, and the wall clock was always the till's own. Anything comparing
|
||||||
|
// billedat against real time did not.
|
||||||
|
func TestASaleDateKeepsBothTheInstantAndTheTradingDay(t *testing.T) {
|
||||||
|
// Coimbatore, late enough that UTC has not yet rolled into the same day.
|
||||||
|
const ist = "2026-08-05T00:30:00+05:30"
|
||||||
|
|
||||||
|
at, err := parsePosSaleDate(ist)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsePosSaleDate(%q) errored: %v", ist, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The instant. 00:30 IST is 19:00 UTC the previous evening.
|
||||||
|
wantInstant := time.Date(2026, 8, 4, 19, 0, 0, 0, time.UTC)
|
||||||
|
if !at.UTC().Equal(wantInstant) {
|
||||||
|
t.Errorf("instant = %v, want %v", at.UTC(), wantInstant)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The trading day. This is the one that must NOT follow UTC — the shop rang
|
||||||
|
// this sale on the 5th and its takings belong to the 5th. Deriving the
|
||||||
|
// business date from UTC would file it under the 4th and leave two days
|
||||||
|
// wrong: one short, one over.
|
||||||
|
if got := at.Format("2006-01-02"); got != "2026-08-05" {
|
||||||
|
t.Errorf("businessdate = %s, want 2026-08-05 — the till's own day", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminals built before the offset was added send a bare local timestamp, and
|
||||||
|
// they are still in the field. Parsing must not start refusing them.
|
||||||
|
//
|
||||||
|
// The instant such a bill records is wrong by the offset and cannot be
|
||||||
|
// recovered — there is nothing in the payload that says which zone it was read
|
||||||
|
// in. Its business date is still right, which is why the daily figures held up,
|
||||||
|
// and why this stays a tolerated legacy rather than a rejection.
|
||||||
|
func TestANaiveSaleDateIsStillAccepted(t *testing.T) {
|
||||||
|
at, err := parsePosSaleDate("2026-08-05T12:49:28.245")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a pre-offset terminal must not be refused: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := at.Format("2006-01-02"); got != "2026-08-05" {
|
||||||
|
t.Errorf("businessdate = %s, want 2026-08-05", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
627
repositories/posUserRepository.go
Normal file
627
repositories/posUserRepository.go
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Till staff, managed by the shop rather than by us.
|
||||||
|
//
|
||||||
|
// A supervisor creates their own cashiers, at their own outlet, from the
|
||||||
|
// terminal. Everything here follows one rule: **the tenant and the outlet come
|
||||||
|
// from the caller's session token and never from the request body.** A
|
||||||
|
// supervisor at Selvapuram cannot create a cashier at R mart by sending a
|
||||||
|
// different number, for the same reason a till cannot bill into another shop.
|
||||||
|
|
||||||
|
// PosPinMin and PosPinMax bound an acceptable PIN.
|
||||||
|
//
|
||||||
|
// Four digits, and never starting with a zero — because `app_users.pin` is a
|
||||||
|
// `bigint`. A PIN of "0451" would be stored as 451 and read back as three
|
||||||
|
// digits, so a cashier would type four and be refused for ever. Live data
|
||||||
|
// already holds one such account.
|
||||||
|
//
|
||||||
|
// Refusing the leading zero costs a shop 1000 of 10000 combinations and buys a
|
||||||
|
// PIN that means the same thing on the way in and on the way out.
|
||||||
|
const (
|
||||||
|
PosPinMin = 1000
|
||||||
|
PosPinMax = 9999
|
||||||
|
)
|
||||||
|
|
||||||
|
// posDefaultAuthname is the username a till account gets when nobody names one.
|
||||||
|
//
|
||||||
|
// Keyed on the outlet and the role rather than on the person, so it survives
|
||||||
|
// staff turnover: a shop replacing its cashier reissues one password instead of
|
||||||
|
// re-teaching a new address. `nth` disambiguates a second account of the same
|
||||||
|
// role at the same counter and is omitted for the first, so the common case
|
||||||
|
// stays the readable one.
|
||||||
|
//
|
||||||
|
// The domain is deliberately not a real one. These are till credentials, never
|
||||||
|
// a mailbox, and an address that looks deliverable invites somebody to try
|
||||||
|
// sending a reset to it.
|
||||||
|
func posDefaultAuthname(roleID, locationID, nth int) string {
|
||||||
|
role := strings.ToLower(models.PosRoleName(roleID))
|
||||||
|
if role == "" {
|
||||||
|
role = "staff"
|
||||||
|
}
|
||||||
|
if nth > 1 {
|
||||||
|
return fmt.Sprintf("%s%d.%d@pos.nearle.in", role, nth, locationID)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s.%d@pos.nearle.in", role, locationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPosPassword generates a till password.
|
||||||
|
//
|
||||||
|
// From crypto/rand, and returned to the caller exactly once — at creation —
|
||||||
|
// because the column it lands in is plaintext and reading it back later should
|
||||||
|
// take a deliberate query rather than an ordinary list call.
|
||||||
|
//
|
||||||
|
// The alphabet drops l, I, O, 0 and 1. These get read off one screen and typed
|
||||||
|
// on another by somebody with a queue in front of them.
|
||||||
|
func newPosPassword() string {
|
||||||
|
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
out := make([]byte, 14)
|
||||||
|
for i := range out {
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||||
|
if err != nil {
|
||||||
|
// crypto/rand failing is not a condition to paper over with a
|
||||||
|
// weaker source; a guessable till password is worse than no till.
|
||||||
|
panic(fmt.Sprintf("generating a till password: %v", err))
|
||||||
|
}
|
||||||
|
out[i] = alphabet[n.Int64()]
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePosUser adds a cashier or supervisor at the caller's outlet.
|
||||||
|
func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error) {
|
||||||
|
roleID := models.PosRoleFromName(req.Role)
|
||||||
|
if roleID == 0 {
|
||||||
|
return nil, fmt.Errorf("role must be 'supervisor' or 'cashier'")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(req.Fullname)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("a name is required")
|
||||||
|
}
|
||||||
|
first, last := splitName(name)
|
||||||
|
|
||||||
|
pin, err := validatePosPin(req.Pin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
password := strings.TrimSpace(req.Password)
|
||||||
|
authname := strings.ToLower(strings.TrimSpace(req.Authname))
|
||||||
|
|
||||||
|
// Every till account gets a username and a password, cashiers included.
|
||||||
|
//
|
||||||
|
// A PIN cannot open a *closed* terminal — the PIN route needs a session that
|
||||||
|
// already exists — so a PIN-only cashier can work only while a supervisor is
|
||||||
|
// standing there to unlock the till first. That is not how a shop opens: the
|
||||||
|
// person who arrives at seven is as often the cashier as the supervisor.
|
||||||
|
//
|
||||||
|
// Generated when the console does not supply them, so provisioning is one
|
||||||
|
// call and nobody has to invent a scheme. An explicit value always wins: a
|
||||||
|
// shop that wants its people signing in as themselves just sends one.
|
||||||
|
//
|
||||||
|
// Whether the name was generated is remembered, because the two cases want
|
||||||
|
// opposite handling on a collision — see the uniqueness check below.
|
||||||
|
nameWasGenerated := authname == ""
|
||||||
|
if nameWasGenerated {
|
||||||
|
authname = posDefaultAuthname(roleID, locationID, 0)
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
password = newPosPassword()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A PIN stays optional. It switches operator at an open counter, which not
|
||||||
|
// every shop does, and it is the one credential the till keeps in plaintext
|
||||||
|
// to hand around — so it is set deliberately, never by default.
|
||||||
|
|
||||||
|
var created *models.PosUser
|
||||||
|
|
||||||
|
err = r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// The advisory lock is for the PIN check below, not for the id.
|
||||||
|
//
|
||||||
|
// `userid` is an identity column — `information_schema.column_default`
|
||||||
|
// is empty for those, which is easy to misread as "no default at all"
|
||||||
|
// and was misread here once. Postgres allocates it, and this must not
|
||||||
|
// compute its own: an explicit id does not advance the sequence, so a
|
||||||
|
// hand-rolled MAX+1 leaves two allocators running in parallel that
|
||||||
|
// eventually land on the same number.
|
||||||
|
//
|
||||||
|
// The lock still earns its place. Two supervisors adding staff at the
|
||||||
|
// same instant could otherwise both find a PIN free and both take it,
|
||||||
|
// and a duplicate PIN attributes a bill to whichever row is read first.
|
||||||
|
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext('app_users'))`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pin > 0 {
|
||||||
|
taken, err := posPinTaken(tx, tenantID, locationID, pin, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
return fmt.Errorf("another person at this outlet already uses that PIN")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uniqueness is checked against `authname` and `email` together because
|
||||||
|
// the insert below writes the same value to both, and
|
||||||
|
// `app_users_email_unique` is a real constraint — a clash there fails the
|
||||||
|
// transaction rather than returning a message anyone can act on.
|
||||||
|
taken := func(candidate string) (bool, error) {
|
||||||
|
var n int64
|
||||||
|
err := tx.Raw(`SELECT COUNT(1) FROM app_users
|
||||||
|
WHERE LOWER(TRIM(authname)) = ? OR LOWER(TRIM(email)) = ?`,
|
||||||
|
candidate, candidate).Scan(&n).Error
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if nameWasGenerated {
|
||||||
|
// Walk to the first free one. Bounded so a bug here cannot spin:
|
||||||
|
// twenty till accounts of one role at a single outlet is already far
|
||||||
|
// past what a counter has, and the error names the fix.
|
||||||
|
found := false
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
clash, err := taken(authname)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !clash {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
authname = posDefaultAuthname(roleID, locationID, i+2)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("this outlet already has too many %s accounts; supply an email explicitly",
|
||||||
|
strings.ToLower(models.PosRoleName(roleID)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clash, err := taken(authname)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if clash {
|
||||||
|
return fmt.Errorf("an account already uses %s", authname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `userid` is omitted so the identity column allocates it, and read back
|
||||||
|
// with RETURNING rather than guessed.
|
||||||
|
//
|
||||||
|
// The email columns go through NULLIF because `app_users_email_unique`
|
||||||
|
// is a real constraint: a second person created without an email would
|
||||||
|
// collide on the empty string, while NULLs do not collide in Postgres.
|
||||||
|
// A cashier who signs in by PIN alone has no email, and that is the
|
||||||
|
// common case.
|
||||||
|
var nextID int
|
||||||
|
if err := tx.Raw(`
|
||||||
|
INSERT INTO app_users
|
||||||
|
(firstname, lastname, authname, email, contactno, password,
|
||||||
|
pin, roleid, configid, tenantid, locationid, status)
|
||||||
|
VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''),
|
||||||
|
NULLIF(?, 0), ?, ?, ?, ?, 'Active')
|
||||||
|
RETURNING userid`,
|
||||||
|
first, last, authname, authname, strings.TrimSpace(req.Contactno),
|
||||||
|
password, pin, roleID, configID, tenantID, locationID,
|
||||||
|
).Scan(&nextID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if nextID <= 0 {
|
||||||
|
return fmt.Errorf("the account was not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
created = &models.PosUser{
|
||||||
|
Userid: nextID,
|
||||||
|
Fullname: name,
|
||||||
|
Firstname: first,
|
||||||
|
Lastname: last,
|
||||||
|
Authname: authname,
|
||||||
|
Contactno: strings.TrimSpace(req.Contactno),
|
||||||
|
Roleid: roleID,
|
||||||
|
Role: models.PosRoleName(roleID),
|
||||||
|
Pin: posPinString(pin),
|
||||||
|
Haspassword: password != "",
|
||||||
|
Locationid: locationID,
|
||||||
|
Status: "Active",
|
||||||
|
|
||||||
|
// The one moment this is ever returned. Listing a till user reports
|
||||||
|
// only whether a password exists, so an admin who loses this has to
|
||||||
|
// reissue rather than look it up — which is the right shape even
|
||||||
|
// while the column itself is plaintext.
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePosUser edits a till user at the caller's outlet.
|
||||||
|
//
|
||||||
|
// Scoped by tenant *and* location in the WHERE clause rather than checked
|
||||||
|
// first: a supervisor sending somebody else's user id updates no rows and is
|
||||||
|
// told so, instead of quietly editing another shop's staff.
|
||||||
|
func (r *posRepository) UpdatePosUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error) {
|
||||||
|
if req.Userid <= 0 {
|
||||||
|
return nil, fmt.Errorf("user_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
sets := []string{}
|
||||||
|
args := []interface{}{}
|
||||||
|
|
||||||
|
if name := strings.TrimSpace(req.Fullname); name != "" {
|
||||||
|
first, last := splitName(name)
|
||||||
|
sets = append(sets, "firstname = ?", "lastname = ?")
|
||||||
|
args = append(args, first, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
if role := strings.TrimSpace(req.Role); role != "" {
|
||||||
|
roleID := models.PosRoleFromName(role)
|
||||||
|
if roleID == 0 {
|
||||||
|
return nil, fmt.Errorf("role must be 'supervisor' or 'cashier'")
|
||||||
|
}
|
||||||
|
sets = append(sets, "roleid = ?")
|
||||||
|
args = append(args, roleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
pin := int64(0)
|
||||||
|
if strings.TrimSpace(req.Pin) != "" {
|
||||||
|
p, err := validatePosPin(req.Pin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pin = p
|
||||||
|
sets = append(sets, "pin = ?")
|
||||||
|
args = append(args, pin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The username a supervisor opens a closed terminal with.
|
||||||
|
//
|
||||||
|
// Editable because a password on its own is unusable: sign-in matches on
|
||||||
|
// `authname` or `contactno`, so an account given a password and no username
|
||||||
|
// cannot be reached by either. This was missing, and the failure was silent
|
||||||
|
// — the update reported success, wrote the password, dropped the username,
|
||||||
|
// and the supervisor was refused at the counter with "not recognised".
|
||||||
|
if authname := strings.TrimSpace(req.Authname); authname != "" {
|
||||||
|
sets = append(sets, "authname = ?")
|
||||||
|
args = append(args, authname)
|
||||||
|
}
|
||||||
|
|
||||||
|
if contactno := strings.TrimSpace(req.Contactno); contactno != "" {
|
||||||
|
sets = append(sets, "contactno = ?")
|
||||||
|
args = append(args, contactno)
|
||||||
|
}
|
||||||
|
|
||||||
|
if password := strings.TrimSpace(req.Password); password != "" {
|
||||||
|
sets = append(sets, "password = ?")
|
||||||
|
args = append(args, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status := strings.TrimSpace(req.Status); status != "" {
|
||||||
|
sets = append(sets, "status = ?")
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sets) == 0 {
|
||||||
|
return nil, fmt.Errorf("nothing to change")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext('app_users'))`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pin > 0 {
|
||||||
|
taken, err := posPinTaken(tx, tenantID, locationID, pin, req.Userid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
return fmt.Errorf("another person at this outlet already uses that PIN")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
`UPDATE app_users SET %s WHERE userid = ? AND tenantid = ? AND locationid = ?`,
|
||||||
|
strings.Join(sets, ", "))
|
||||||
|
args = append(args, req.Userid, tenantID, locationID)
|
||||||
|
|
||||||
|
result := tx.Exec(query, args...)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("no user %d at this outlet", req.Userid)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := r.ListPosUsers(tenantID, locationID, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range users {
|
||||||
|
if users[i].Userid == req.Userid {
|
||||||
|
return &users[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPosUsers returns the till users at an outlet.
|
||||||
|
func (r *posRepository) ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error) {
|
||||||
|
rows := make([]struct {
|
||||||
|
Userid int
|
||||||
|
Firstname string
|
||||||
|
Lastname string
|
||||||
|
Authname string
|
||||||
|
Contactno string
|
||||||
|
Roleid int
|
||||||
|
Pin int64
|
||||||
|
Haspassword bool
|
||||||
|
Status string
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT userid,
|
||||||
|
COALESCE(firstname,'') AS firstname, COALESCE(lastname,'') AS lastname,
|
||||||
|
COALESCE(authname,'') AS authname, COALESCE(contactno,'') AS contactno,
|
||||||
|
COALESCE(roleid,0) AS roleid, COALESCE(pin,0) AS pin,
|
||||||
|
(COALESCE(password,'') <> '') AS haspassword,
|
||||||
|
COALESCE(status,'') AS status
|
||||||
|
FROM app_users
|
||||||
|
WHERE tenantid = ? AND locationid = ?
|
||||||
|
AND COALESCE(roleid,0) IN (?, ?)`
|
||||||
|
params := []interface{}{tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier}
|
||||||
|
|
||||||
|
if !includeInactive {
|
||||||
|
query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'`
|
||||||
|
}
|
||||||
|
query += ` ORDER BY userid`
|
||||||
|
|
||||||
|
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
users := make([]models.PosUser, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
users = append(users, models.PosUser{
|
||||||
|
Userid: row.Userid,
|
||||||
|
Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname),
|
||||||
|
Firstname: row.Firstname,
|
||||||
|
Lastname: row.Lastname,
|
||||||
|
Authname: row.Authname,
|
||||||
|
Contactno: row.Contactno,
|
||||||
|
Roleid: row.Roleid,
|
||||||
|
Role: models.PosRoleName(row.Roleid),
|
||||||
|
Pin: posPinString(row.Pin),
|
||||||
|
Haspassword: row.Haspassword,
|
||||||
|
Locationid: locationID,
|
||||||
|
Status: row.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivatePosUser retires somebody without deleting them.
|
||||||
|
//
|
||||||
|
// Bills carry the cashier's name and shifts settle against it, so a hard delete
|
||||||
|
// would orphan a day's takings.
|
||||||
|
func (r *posRepository) DeactivatePosUser(tenantID, locationID, userID int) error {
|
||||||
|
result := r.db.Exec(`
|
||||||
|
UPDATE app_users SET status = 'InActive'
|
||||||
|
WHERE userid = ? AND tenantid = ? AND locationid = ?
|
||||||
|
AND COALESCE(roleid,0) IN (?, ?)`,
|
||||||
|
userID, tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier)
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
// Either no such person, or they belong to another shop, or they are a
|
||||||
|
// back-office account rather than till staff. One message for all three
|
||||||
|
// — distinguishing them tells a caller about rows they cannot see.
|
||||||
|
return fmt.Errorf("no till user %d at this outlet", userID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosLoginByPin signs somebody in with a PIN alone, inside an outlet.
|
||||||
|
//
|
||||||
|
// A PIN is four digits, so this must never be reachable by an anonymous caller
|
||||||
|
// — ten thousand guesses is not a barrier. It is only called with a tenant and
|
||||||
|
// location taken from an *already valid* session token, which means a
|
||||||
|
// supervisor has opened the terminal with a real password first and the guesses
|
||||||
|
// are confined to one outlet's own staff.
|
||||||
|
func (r *posRepository) PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error) {
|
||||||
|
value, err := validatePosPin(pin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errPosLoginRejected
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []posLoginRow
|
||||||
|
err = r.db.Raw(`
|
||||||
|
SELECT userid, COALESCE(password,'') AS password, COALESCE(status,'') AS status,
|
||||||
|
COALESCE(roleid,0) AS roleid, COALESCE(configid,0) AS configid,
|
||||||
|
COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
|
||||||
|
COALESCE(firstname,'') AS firstname, COALESCE(lastname,'') AS lastname,
|
||||||
|
COALESCE(email,'') AS email
|
||||||
|
FROM app_users
|
||||||
|
WHERE tenantid = ? AND locationid = ? AND pin = ?
|
||||||
|
AND LOWER(COALESCE(status,'active')) <> 'inactive'
|
||||||
|
ORDER BY userid`, tenantID, locationID, value).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, errPosLoginRejected
|
||||||
|
}
|
||||||
|
// Two people on one PIN would attribute a bill to whichever row was read
|
||||||
|
// first. Creation refuses a duplicate, but data predating this endpoint
|
||||||
|
// need not have, so it is refused here too rather than guessed.
|
||||||
|
if len(rows) > 1 {
|
||||||
|
return nil, fmt.Errorf("more than one person at this outlet uses that PIN; ask a supervisor to change one of them")
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.sessionFor(rows[0], locationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// posPinTaken reports whether a PIN is already in use at an outlet.
|
||||||
|
//
|
||||||
|
// Scoped to the outlet rather than globally, because a PIN only ever
|
||||||
|
// distinguishes people standing at the same counter — making them unique across
|
||||||
|
// the platform would exhaust nine thousand combinations very quickly.
|
||||||
|
func posPinTaken(tx *gorm.DB, tenantID, locationID int, pin int64, exceptUser int) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := tx.Raw(`
|
||||||
|
SELECT COUNT(1) FROM app_users
|
||||||
|
WHERE tenantid = ? AND locationid = ? AND pin = ? AND userid <> ?
|
||||||
|
AND LOWER(COALESCE(status,'active')) <> 'inactive'`,
|
||||||
|
tenantID, locationID, pin, exceptUser).Scan(&count).Error
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePosPin checks a PIN is one this schema can store faithfully.
|
||||||
|
func validatePosPin(raw string) (int64, error) {
|
||||||
|
pin := strings.TrimSpace(raw)
|
||||||
|
if pin == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pin) != 4 {
|
||||||
|
return 0, fmt.Errorf("a PIN is exactly 4 digits")
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseInt(pin, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("a PIN is digits only")
|
||||||
|
}
|
||||||
|
if value < PosPinMin || value > PosPinMax {
|
||||||
|
// Which is to say: it started with a zero. Said plainly, because "a PIN
|
||||||
|
// is 4 digits" would be baffling to somebody who just typed four.
|
||||||
|
return 0, fmt.Errorf("a PIN cannot start with 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first thing anyone tries, and live data already has 1234 on eleven
|
||||||
|
// accounts and 1111 on nine.
|
||||||
|
switch pin {
|
||||||
|
case "1234", "1111", "0000", "2345", "3456", "4321", "9999", "2222":
|
||||||
|
return 0, fmt.Errorf("that PIN is too easy to guess; choose another")
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// posPinString renders a stored PIN.
|
||||||
|
//
|
||||||
|
// Anything the schema cannot represent as four digits comes back empty rather
|
||||||
|
// than short: a three-digit PIN on screen is one a cashier cannot type, and
|
||||||
|
// showing it would send them to a supervisor for a fault they cannot describe.
|
||||||
|
func posPinString(pin int64) string {
|
||||||
|
if pin < PosPinMin || pin > PosPinMax {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(pin, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitName turns a typed name into the two columns this schema has.
|
||||||
|
func splitName(full string) (first, last string) {
|
||||||
|
parts := strings.Fields(strings.TrimSpace(full))
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return parts[0], ""
|
||||||
|
}
|
||||||
|
return parts[0], strings.Join(parts[1:], " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateStaffUser applies the till's rules to a staff row from anywhere.
|
||||||
|
//
|
||||||
|
// Exported because the web console writes `app_users` too, through
|
||||||
|
// `tenants/createstaff`, and that path had no validation whatsoever — no PIN
|
||||||
|
// rules, no role check, no duplicate check. A cashier created there could be
|
||||||
|
// given "0451", which a bigint column stores as 451, and would then type four
|
||||||
|
// digits at the counter and be refused for ever with nothing to explain it.
|
||||||
|
//
|
||||||
|
// Two paths writing one table drift apart. This is the shared rule set, so a
|
||||||
|
// person created from a browser and a person created from a till are subject to
|
||||||
|
// the same constraints and behave the same way at the counter.
|
||||||
|
//
|
||||||
|
// Returns the parsed PIN, or an error a caller can show to whoever typed it.
|
||||||
|
func ValidateStaffUser(user *models.User) (int64, error) {
|
||||||
|
if strings.TrimSpace(user.Firstname+user.Lastname) == "" {
|
||||||
|
return 0, fmt.Errorf("a name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the roles this platform actually defines. `roleid` 0 is the one that
|
||||||
|
// matters: it is not a role, it is what a row carries when nobody set one,
|
||||||
|
// and live data has riders and shop accounts sharing it.
|
||||||
|
if user.Roleid <= 0 {
|
||||||
|
return 0, fmt.Errorf("a role is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
pin := int64(user.Pin)
|
||||||
|
if pin != 0 {
|
||||||
|
parsed, err := validatePosPin(strconv.FormatInt(pin, 10))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
pin = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if pin == 0 && strings.TrimSpace(user.Password) == "" {
|
||||||
|
return 0, fmt.Errorf("set a PIN, a password, or both — otherwise this person cannot sign in")
|
||||||
|
}
|
||||||
|
|
||||||
|
return pin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaffPinAvailable reports whether a PIN is free at an outlet.
|
||||||
|
//
|
||||||
|
// Exported for the same reason as [ValidateStaffUser]: the web console needs
|
||||||
|
// the check the till already makes. Two people sharing a PIN would attribute a
|
||||||
|
// bill to whichever row happened to be read first.
|
||||||
|
func (r *posRepository) StaffPinAvailable(tenantID, locationID int, pin int64, exceptUser int) (bool, error) {
|
||||||
|
if pin == 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
taken, err := posPinTaken(r.db, tenantID, locationID, pin, exceptUser)
|
||||||
|
return !taken, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosConfigidFor returns the configid an outlet's people already use.
|
||||||
|
//
|
||||||
|
// The console cannot sensibly be asked for this. It is a number nobody looks
|
||||||
|
// up, it varies per tenant — live data has tenant 1087 spread across 1, 6 and
|
||||||
|
// 15 — and getting it wrong creates an account that cannot sign into the portal
|
||||||
|
// its colleagues use and is invisible to half the platform's queries.
|
||||||
|
//
|
||||||
|
// So it is inferred from whichever value that tenant's existing accounts most
|
||||||
|
// commonly carry. Returns 0 for a tenant with no accounts at all, which is
|
||||||
|
// simply what a fresh tenant looks like.
|
||||||
|
func (r *posRepository) PosConfigidFor(tenantID int) int {
|
||||||
|
var configID int
|
||||||
|
r.db.Raw(`SELECT COALESCE(configid, 0) FROM app_users
|
||||||
|
WHERE tenantid = ? AND COALESCE(configid, 0) > 0
|
||||||
|
GROUP BY configid ORDER BY COUNT(*) DESC, configid LIMIT 1`,
|
||||||
|
tenantID).Scan(&configID)
|
||||||
|
return configID
|
||||||
|
}
|
||||||
213
repositories/posUserRepository_test.go
Normal file
213
repositories/posUserRepository_test.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A PIN has to survive a round trip through a `bigint` column, and has to be
|
||||||
|
// hard enough to guess to be worth having. These cover both, because the schema
|
||||||
|
// makes the first one non-obvious.
|
||||||
|
|
||||||
|
func TestAPinMustSurviveTheColumnItIsStoredIn(t *testing.T) {
|
||||||
|
// `app_users.pin` is a bigint. "0451" stored there comes back as 451, so a
|
||||||
|
// cashier would type four digits and be refused for ever. Live data already
|
||||||
|
// holds one such account.
|
||||||
|
if _, err := validatePosPin("0451"); err == nil {
|
||||||
|
t.Fatal("a PIN starting with zero was accepted; it cannot round-trip through a bigint")
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := validatePosPin("4821")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a good PIN was refused: %v", err)
|
||||||
|
}
|
||||||
|
if value != 4821 {
|
||||||
|
t.Fatalf("PIN parsed to %d, want 4821", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPinIsExactlyFourDigits(t *testing.T) {
|
||||||
|
for _, pin := range []string{"123", "12345", "abcd", "12a4", " 12 "} {
|
||||||
|
if _, err := validatePosPin(pin); err == nil {
|
||||||
|
t.Errorf("PIN %q was accepted", pin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first thing anyone tries. Live data has 1234 on eleven accounts and 1111
|
||||||
|
// on nine, which is exactly the outcome this prevents repeating.
|
||||||
|
func TestAnObviousPinIsRefused(t *testing.T) {
|
||||||
|
for _, pin := range []string{"1234", "1111", "2345", "4321", "9999", "2222"} {
|
||||||
|
if _, err := validatePosPin(pin); err == nil {
|
||||||
|
t.Errorf("PIN %q was accepted despite being one of the first guessed", pin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty PIN is not an error — somebody may be given a password instead. The
|
||||||
|
// caller decides whether having neither is a problem.
|
||||||
|
func TestAnAbsentPinIsNotAnError(t *testing.T) {
|
||||||
|
value, err := validatePosPin("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("an absent PIN was treated as invalid: %v", err)
|
||||||
|
}
|
||||||
|
if value != 0 {
|
||||||
|
t.Fatalf("an absent PIN parsed to %d, want 0", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored PIN the schema cannot represent as four digits comes back empty
|
||||||
|
// rather than short, because a three-digit PIN on screen is one a cashier
|
||||||
|
// cannot type — and they would have no way to describe the fault.
|
||||||
|
func TestAnUnrepresentablePinIsNotShown(t *testing.T) {
|
||||||
|
if got := posPinString(451); got != "" {
|
||||||
|
t.Fatalf("a three-digit PIN rendered as %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := posPinString(0); got != "" {
|
||||||
|
t.Fatalf("an unset PIN rendered as %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := posPinString(4821); got != "4821" {
|
||||||
|
t.Fatalf("PIN rendered as %q, want 4821", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANameIsSplitAcrossTheTwoColumnsThisSchemaHas(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
first, last string
|
||||||
|
}{
|
||||||
|
{"Asha", "Asha", ""},
|
||||||
|
{"Asha Kumar", "Asha", "Kumar"},
|
||||||
|
{"Ragul Kannan Selvam", "Ragul", "Kannan Selvam"},
|
||||||
|
{" Divya R ", "Divya", "R"},
|
||||||
|
{"", "", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
first, last := splitName(tc.in)
|
||||||
|
if first != tc.first || last != tc.last {
|
||||||
|
t.Errorf("splitName(%q) = (%q, %q), want (%q, %q)",
|
||||||
|
tc.in, first, last, tc.first, tc.last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a role that can actually be checked should grant anything. Zero is the
|
||||||
|
// one that matters: it is not a role, it is what an account carries when nobody
|
||||||
|
// set one, and live data has riders and shop accounts sharing it.
|
||||||
|
func TestOnlyRealRolesCanManageStaff(t *testing.T) {
|
||||||
|
if models.PosRoleCanManageStaff(0) {
|
||||||
|
t.Error("roleid 0 was allowed to manage staff; it is unset, not a role")
|
||||||
|
}
|
||||||
|
if models.PosRoleCanManageStaff(models.PosRoleCashier) {
|
||||||
|
t.Error("a cashier was allowed to manage staff, so could promote themselves")
|
||||||
|
}
|
||||||
|
if !models.PosRoleCanManageStaff(models.PosRoleSupervisor) {
|
||||||
|
t.Error("a supervisor was refused staff management, which is their whole purpose")
|
||||||
|
}
|
||||||
|
// A Nearle Daily role is not a POS role. This once granted staff management
|
||||||
|
// to 1 through 6, on the reasoning that a browser administrator loses
|
||||||
|
// nothing by standing at the counter — which handed till-supervisor powers
|
||||||
|
// to 68 live accounts, 59 of them platform Super admins, not one of them
|
||||||
|
// anybody's POS administrator. The back office provisions a supervisor; it
|
||||||
|
// does not become one.
|
||||||
|
for _, role := range []int{1, 2, 3, 4, 5, 6} {
|
||||||
|
if models.PosRoleCanManageStaff(role) {
|
||||||
|
t.Errorf("back-office role %d was granted till staff management", role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The till and the Nearle Daily application share one table and nothing else.
|
||||||
|
// Eligibility is provisioned, never inherited.
|
||||||
|
func TestOnlyPosRolesCanOpenATill(t *testing.T) {
|
||||||
|
for _, role := range []int{models.PosRoleSupervisor, models.PosRoleCashier} {
|
||||||
|
if !models.PosRoleEligible(role) {
|
||||||
|
t.Errorf("POS role %d was refused a till", role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Zero matters most: it is not a role but the absence of one, and 22 live
|
||||||
|
// accounts carry it, including a delivery rider.
|
||||||
|
for _, role := range []int{0, 1, 2, 3, 4, 5, 6, 9, 99, -1} {
|
||||||
|
if models.PosRoleEligible(role) {
|
||||||
|
t.Errorf("non-POS role %d was allowed to open a till", role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARoleIsReadFromItsNameNotItsNumber(t *testing.T) {
|
||||||
|
if got := models.PosRoleFromName("supervisor"); got != models.PosRoleSupervisor {
|
||||||
|
t.Errorf("supervisor = %d, want %d", got, models.PosRoleSupervisor)
|
||||||
|
}
|
||||||
|
if got := models.PosRoleFromName(" Cashier "); got != models.PosRoleCashier {
|
||||||
|
t.Errorf("cashier = %d, want %d", got, models.PosRoleCashier)
|
||||||
|
}
|
||||||
|
// Anything unrecognised is zero, and every caller treats zero as a refusal
|
||||||
|
// rather than as a default — an unknown role must never become a supervisor.
|
||||||
|
for _, name := range []string{"", "admin", "manager", "owner", "7"} {
|
||||||
|
if got := models.PosRoleFromName(name); got != 0 {
|
||||||
|
t.Errorf("PosRoleFromName(%q) = %d, want 0", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The web console writes `app_users` too, through `tenants/createstaff`, and
|
||||||
|
// that path had no validation at all. These cover the shared rule set, so a
|
||||||
|
// person created from a browser is subject to the same constraints as one
|
||||||
|
// created at a till — two paths writing one table is how they drift.
|
||||||
|
|
||||||
|
func TestStaffFromTheWebConsoleObeysTheTillsRules(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
user models.User
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "a usable cashier",
|
||||||
|
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Pin: 7391},
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a password instead of a PIN is fine",
|
||||||
|
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Password: "s3cret"},
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no name",
|
||||||
|
user: models.User{Roleid: models.PosRoleCashier, Pin: 7391},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no role — 0 is unset, not a role",
|
||||||
|
user: models.User{Firstname: "Asha", Pin: 7391},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no way at all to sign in",
|
||||||
|
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 451 is what "0451" becomes in a bigint column. Accepting it here
|
||||||
|
// creates somebody who types four digits and is refused for ever.
|
||||||
|
name: "a PIN the column cannot hold",
|
||||||
|
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Pin: 451},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a PIN anyone would guess first",
|
||||||
|
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Pin: 1234},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
user := tc.user
|
||||||
|
_, err := ValidateStaffUser(&user)
|
||||||
|
|
||||||
|
if tc.ok && err != nil {
|
||||||
|
t.Fatalf("refused a valid staff row: %v", err)
|
||||||
|
}
|
||||||
|
if !tc.ok && err == nil {
|
||||||
|
t.Fatal("accepted a staff row the till could not use")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -320,10 +320,13 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
|
|||||||
a.email,a.contactno,a.address,a.suburb,a.city,
|
a.email,a.contactno,a.address,a.suburb,a.city,
|
||||||
a.state,a.postcode,a.userfcmtoken,a.pin,a.applocationid,
|
a.state,a.postcode,a.userfcmtoken,a.pin,a.applocationid,
|
||||||
a.roleid,a.partnerid,a.tenantid,a.locationid,
|
a.roleid,a.partnerid,a.tenantid,a.locationid,
|
||||||
b.locationname
|
b.locationname,
|
||||||
|
COALESCE(c.rolename,'') AS rolename
|
||||||
FROM app_users a
|
FROM app_users a
|
||||||
INNER JOIN tenantlocations b ON a.locationid = b.locationid
|
INNER JOIN tenantlocations b ON a.locationid = b.locationid
|
||||||
WHERE a.tenantid = ?`
|
LEFT JOIN app_roles c ON c.roleid = a.roleid
|
||||||
|
WHERE a.tenantid = ?
|
||||||
|
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
|
||||||
|
|
||||||
if err := r.db.Raw(q1, tid).Scan(&data).Error; err != nil {
|
if err := r.db.Raw(q1, tid).Scan(&data).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -332,7 +335,34 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateStaff adds a person to a shop from the web console.
|
||||||
|
//
|
||||||
|
// Now subject to the same rules the till applies — see ValidateStaffUser. This
|
||||||
|
// wrote whatever it was handed, so a cashier could be created with a PIN the
|
||||||
|
// schema cannot store, a PIN somebody else already has, or no way to sign in at
|
||||||
|
// all. The failure surfaced at the counter rather than on the screen that
|
||||||
|
// caused it.
|
||||||
|
//
|
||||||
|
// `userid` is deliberately not set: it is a `GENERATED BY DEFAULT AS IDENTITY`
|
||||||
|
// column and Postgres allocates it. Computing one here would leave the sequence
|
||||||
|
// unadvanced and two allocators racing each other.
|
||||||
func (r *tenantRepository) CreateStaff(user models.User) error {
|
func (r *tenantRepository) CreateStaff(user models.User) error {
|
||||||
|
pin, err := ValidateStaffUser(&user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.Pin = int(pin)
|
||||||
|
|
||||||
|
if pin > 0 && user.Tenantid > 0 && user.Locationid > 0 {
|
||||||
|
taken, err := posPinTaken(r.db, user.Tenantid, user.Locationid, pin, user.Userid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
return fmt.Errorf("another person at this outlet already uses that PIN")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.db.Table("app_users").Create(&user).Error; err != nil {
|
if err := r.db.Table("app_users").Create(&user).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,18 @@ func (r *userRepository) GetAllUsers(roleID, tenantID, pageno, pagesize int, key
|
|||||||
LEFT JOIN ridershifts c ON a.shiftid = c.shiftid
|
LEFT JOIN ridershifts c ON a.shiftid = c.shiftid
|
||||||
WHERE 1=1`)
|
WHERE 1=1`)
|
||||||
|
|
||||||
|
// Till accounts are not Nearle Daily users and must not be listed as though
|
||||||
|
// they were. The two products share this table and nothing else: a cashier
|
||||||
|
// has no app login, no rider shift and no back-office screen, so a row
|
||||||
|
// returned here is one every action on the page would fail against.
|
||||||
|
//
|
||||||
|
// Asking for 7 or 8 by name still works, so the POS console can read its own
|
||||||
|
// people through the same endpoint — this hides them from the general list,
|
||||||
|
// it does not make them unreachable.
|
||||||
|
if roleID != models.PosRoleSupervisor && roleID != models.PosRoleCashier {
|
||||||
|
queryBuilder.WriteString(" AND COALESCE(a.roleid, 0) NOT IN (7, 8)")
|
||||||
|
}
|
||||||
|
|
||||||
if roleID != 0 {
|
if roleID != 0 {
|
||||||
queryBuilder.WriteString(" AND a.roleid = ?")
|
queryBuilder.WriteString(" AND a.roleid = ?")
|
||||||
params = append(params, roleID)
|
params = append(params, roleID)
|
||||||
@@ -78,8 +90,6 @@ func (r *userRepository) GetAllUsers(roleID, tenantID, pageno, pagesize int, key
|
|||||||
queryBuilder.WriteString(" ORDER BY a.userid DESC LIMIT ? OFFSET ?")
|
queryBuilder.WriteString(" ORDER BY a.userid DESC LIMIT ? OFFSET ?")
|
||||||
params = append(params, pagesize, offset)
|
params = append(params, pagesize, offset)
|
||||||
|
|
||||||
print(queryBuilder.String())
|
|
||||||
|
|
||||||
if err := r.db.Raw(queryBuilder.String(), params...).Scan(&users).Error; err != nil {
|
if err := r.db.Raw(queryBuilder.String(), params...).Scan(&users).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -119,14 +129,16 @@ func (r *userRepository) Login(user models.User) (models.UserInfo, error) {
|
|||||||
|
|
||||||
var q string
|
var q string
|
||||||
if user.Authname != "" {
|
if user.Authname != "" {
|
||||||
q = `SELECT a.userid FROM app_users a
|
q = `SELECT a.userid FROM app_users a
|
||||||
WHERE a.authname = ? AND a.configid = ?`
|
WHERE a.authname = ? AND a.configid = ?
|
||||||
|
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
|
||||||
if err := r.db.Raw(q, user.Authname, user.Configid).Scan(&uid).Error; err != nil {
|
if err := r.db.Raw(q, user.Authname, user.Configid).Scan(&uid).Error; err != nil {
|
||||||
return models.UserInfo{}, err
|
return models.UserInfo{}, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
q = `SELECT a.userid FROM app_users a
|
q = `SELECT a.userid FROM app_users a
|
||||||
WHERE a.contactno = ? AND a.configid = ?`
|
WHERE a.contactno = ? AND a.configid = ?
|
||||||
|
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
|
||||||
if err := r.db.Raw(q, user.Contactno, user.Configid).Scan(&uid).Error; err != nil {
|
if err := r.db.Raw(q, user.Contactno, user.Configid).Scan(&uid).Error; err != nil {
|
||||||
return models.UserInfo{}, err
|
return models.UserInfo{}, err
|
||||||
}
|
}
|
||||||
@@ -159,12 +171,16 @@ func (r *userRepository) FindUserID(authname, contactno string, configid int) (i
|
|||||||
var query string
|
var query string
|
||||||
|
|
||||||
if authname != "" {
|
if authname != "" {
|
||||||
query = `SELECT a.userid FROM app_users a WHERE a.authname = ? AND a.configid = ?`
|
query = `SELECT a.userid FROM app_users a
|
||||||
|
WHERE a.authname = ? AND a.configid = ?
|
||||||
|
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
|
||||||
if err := r.db.Raw(query, authname, configid).Scan(&uid).Error; err != nil {
|
if err := r.db.Raw(query, authname, configid).Scan(&uid).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
query = `SELECT a.userid FROM app_users a WHERE a.contactno = ? AND a.configid = ?`
|
query = `SELECT a.userid FROM app_users a
|
||||||
|
WHERE a.contactno = ? AND a.configid = ?
|
||||||
|
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
|
||||||
if err := r.db.Raw(query, contactno, configid).Scan(&uid).Error; err != nil {
|
if err := r.db.Raw(query, contactno, configid).Scan(&uid).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -189,10 +205,19 @@ func (r *userRepository) UpdateStaff(user models.User) error {
|
|||||||
return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error
|
return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A till account is not a Nearle Daily user. The two products share this table
|
||||||
|
// and nothing else, so every way into the application excludes roles 7 and 8 in
|
||||||
|
// the lookup itself: a cashier is not "refused", they are simply not found.
|
||||||
|
//
|
||||||
|
// Doing it in the query rather than after it is deliberate. A check bolted on
|
||||||
|
// afterwards has to be repeated at each of these call sites and is one edit away
|
||||||
|
// from being forgotten at one of them, and that one would be the hole.
|
||||||
func (r *userRepository) GetUserByAuthname(authname string, configid int) (int, string, string) {
|
func (r *userRepository) GetUserByAuthname(authname string, configid int) (int, string, string) {
|
||||||
var uid int
|
var uid int
|
||||||
var password, status string
|
var password, status string
|
||||||
query := `SELECT userid, password, status FROM app_users WHERE authname = ? AND configid = ?`
|
query := `SELECT userid, password, status FROM app_users
|
||||||
|
WHERE authname = ? AND configid = ?
|
||||||
|
AND COALESCE(roleid, 0) NOT IN (7, 8)`
|
||||||
r.db.Raw(query, authname, configid).Row().Scan(&uid, &password, &status)
|
r.db.Raw(query, authname, configid).Row().Scan(&uid, &password, &status)
|
||||||
return uid, password, status
|
return uid, password, status
|
||||||
}
|
}
|
||||||
@@ -200,7 +225,9 @@ func (r *userRepository) GetUserByAuthname(authname string, configid int) (int,
|
|||||||
func (r *userRepository) GetUserByContactNo(contactno string, configid int) (int, string, string) {
|
func (r *userRepository) GetUserByContactNo(contactno string, configid int) (int, string, string) {
|
||||||
var uid int
|
var uid int
|
||||||
var password, status string
|
var password, status string
|
||||||
query := `SELECT userid, password, status FROM app_users WHERE contactno = ? AND configid = ?`
|
query := `SELECT userid, password, status FROM app_users
|
||||||
|
WHERE contactno = ? AND configid = ?
|
||||||
|
AND COALESCE(roleid, 0) NOT IN (7, 8)`
|
||||||
r.db.Raw(query, contactno, configid).Row().Scan(&uid, &password, &status)
|
r.db.Raw(query, contactno, configid).Row().Scan(&uid, &password, &status)
|
||||||
return uid, password, status
|
return uid, password, status
|
||||||
}
|
}
|
||||||
@@ -282,7 +309,8 @@ func (r *userRepository) GetUserLogin(field, value string, configid int) (int, s
|
|||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
SELECT userid, password, status, roleid
|
SELECT userid, password, status, roleid
|
||||||
FROM app_users
|
FROM app_users
|
||||||
WHERE %s = ? AND configid = ?`, field)
|
WHERE %s = ? AND configid = ?
|
||||||
|
AND COALESCE(roleid, 0) NOT IN (7, 8)`, field)
|
||||||
|
|
||||||
r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)
|
r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package routes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"nearle/facade"
|
"nearle/facade"
|
||||||
|
"nearle/middleware"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -19,10 +20,48 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
|
|
||||||
pos := api.Group("/v1/pos")
|
pos := api.Group("/v1/pos")
|
||||||
|
|
||||||
|
// Sign-in, and the only route on this group that runs before the guard —
|
||||||
|
// it is where a session comes from. A till posts the same `app_users`
|
||||||
|
// credentials the web console takes, and gets back a token plus the outlet
|
||||||
|
// that account is entitled to. The store id it will bill under is decided
|
||||||
|
// here, from the user's record, instead of being typed into Settings and
|
||||||
|
// taken on trust.
|
||||||
|
pos.Post("/login", f.PosController.Login)
|
||||||
|
|
||||||
|
// Everything past this point carries the session.
|
||||||
|
//
|
||||||
|
// The guard verifies the token and refuses a request naming an outlet the
|
||||||
|
// token's tenant does not own. Until `POS_AUTH_REQUIRED=true` is set it
|
||||||
|
// lets an unauthenticated request through, so the terminals already
|
||||||
|
// trading do not stop the day this deploys — see middleware.PosAuth.
|
||||||
|
pos.Use(middleware.PosAuth(f.PosService()))
|
||||||
|
|
||||||
|
pos.Get("/session", f.PosController.Session)
|
||||||
|
|
||||||
|
// Who may ring a bill here. Deliberately takes no location parameter — the
|
||||||
|
// answer carries PINs, so the outlet comes from the caller's own token.
|
||||||
|
pos.Get("/staff", f.PosController.Staff)
|
||||||
|
|
||||||
|
// Signing on by PIN, once a supervisor has opened the terminal with a real
|
||||||
|
// password. Sits behind the guard on purpose — see PinLogin.
|
||||||
|
pos.Post("/login/pin", f.PosController.PinLogin)
|
||||||
|
|
||||||
|
// The shop's own counter staff. A supervisor creates their cashiers; the
|
||||||
|
// outlet is always the caller's own, read from their token.
|
||||||
|
pos.Get("/users", f.PosController.ListPosUsers)
|
||||||
|
pos.Post("/users", f.PosController.CreatePosUser)
|
||||||
|
pos.Put("/users", f.PosController.UpdatePosUser)
|
||||||
|
pos.Delete("/users", f.PosController.DeletePosUser)
|
||||||
|
|
||||||
pos.Post("/orders", f.PosController.IngestOrders)
|
pos.Post("/orders", f.PosController.IngestOrders)
|
||||||
pos.Post("/customers", f.PosController.IngestCustomers)
|
pos.Post("/customers", f.PosController.IngestCustomers)
|
||||||
pos.Get("/catalogue", f.PosController.Catalogue)
|
pos.Get("/catalogue", f.PosController.Catalogue)
|
||||||
|
|
||||||
|
// The 30-second heartbeat, for tills on the HTTP route. The broker carries
|
||||||
|
// the same payload for tills on MQTT; both land in the same Redis record,
|
||||||
|
// so the fleet board cannot tell them apart and does not need to.
|
||||||
|
pos.Post("/health", f.PosController.IngestHealth)
|
||||||
|
|
||||||
// Counter sales, read back out. The ingest above only ever writes; without
|
// Counter sales, read back out. The ingest above only ever writes; without
|
||||||
// these a committed bill is unreachable from every screen in the product.
|
// these a committed bill is unreachable from every screen in the product.
|
||||||
pos.Get("/sales", f.PosController.GetSales)
|
pos.Get("/sales", f.PosController.GetSales)
|
||||||
@@ -34,4 +73,37 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
// broker rather than posting it here.
|
// broker rather than posting it here.
|
||||||
pos.Get("/health/terminal", f.PosController.TerminalHealth)
|
pos.Get("/health/terminal", f.PosController.TerminalHealth)
|
||||||
pos.Get("/health/location", f.PosController.LocationHealth)
|
pos.Get("/health/location", f.PosController.LocationHealth)
|
||||||
|
|
||||||
|
registerPosStaffConsoleRoutes(api, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Till staff, managed from the web console rather than from a counter.
|
||||||
|
//
|
||||||
|
// Under `/web` and `/mob` rather than `/pos`, because the callers are the back
|
||||||
|
// office and the daily app — neither holds a terminal session, and putting them
|
||||||
|
// behind the terminal guard would lock out the very screen an admin uses to set
|
||||||
|
// a shop up in the first place.
|
||||||
|
//
|
||||||
|
// They run the same service calls as `/pos/users`. A supervisor created here is
|
||||||
|
// the same row, with the same rules applied, as one created at a till.
|
||||||
|
//
|
||||||
|
// The outlet is asserted rather than proved, which is the real difference and
|
||||||
|
// the weaker half: a terminal signs its outlet, a console just names one. It is
|
||||||
|
// checked against the tenant before anything is written, and these should move
|
||||||
|
// behind a session guard as soon as the console can hold one — until then, this
|
||||||
|
// mints till credentials on the strength of an unauthenticated request, exactly
|
||||||
|
// like every other route in this group.
|
||||||
|
func registerPosStaffConsoleRoutes(api fiber.Router, f *facade.Facade) {
|
||||||
|
for _, group := range []string{"/v1/web/tenants", "/v1/mob/tenants"} {
|
||||||
|
g := api.Group(group)
|
||||||
|
|
||||||
|
// Served rather than hardcoded, so a console offering the choice does
|
||||||
|
// not have to know that supervisor is 7.
|
||||||
|
g.Get("/posroles", f.PosController.WebPosRoles)
|
||||||
|
|
||||||
|
g.Get("/getposusers", f.PosController.WebListPosUsers)
|
||||||
|
g.Post("/createposuser", f.PosController.WebCreatePosUser)
|
||||||
|
g.Put("/updateposuser", f.PosController.WebUpdatePosUser)
|
||||||
|
g.Delete("/deleteposuser", f.PosController.WebDeletePosUser)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
180
scratch/gstrates/main.go
Normal file
180
scratch/gstrates/main.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
// Set GST rates on the POS catalogue products.
|
||||||
|
//
|
||||||
|
// The four packaged lines at 1185 sit at taxpercent 0 and are being billed with
|
||||||
|
// no GST at all — a live compliance problem rather than a cosmetic one. Those
|
||||||
|
// are written.
|
||||||
|
//
|
||||||
|
// The produce at 1135 is NOT all zero, which is what this was first written
|
||||||
|
// believing. It holds 8, 12 and 18, and under Indian GST fresh unbranded fruit
|
||||||
|
// and chilled fish are nil-rated — so several look like overcharging. Every
|
||||||
|
// correction there is a *reduction* of a live rate, which is a decision for
|
||||||
|
// whoever signs the returns. Reported as REVIEW and left untouched.
|
||||||
|
//
|
||||||
|
// go run ./scratch/gstrates plan
|
||||||
|
// go run ./scratch/gstrates apply
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Indian GST on food, as it applies to these lines.
|
||||||
|
//
|
||||||
|
// Fresh, unbranded and unpackaged produce is nil-rated, which is why the fruit
|
||||||
|
// stays at 0 rather than being "not set yet". Packaged branded snacks are 12%.
|
||||||
|
// Breakfast cereal is 18%.
|
||||||
|
//
|
||||||
|
// Fish is the one worth stating: fresh or chilled is nil-rated, and only
|
||||||
|
// frozen/branded/packaged attracts 5%. Left at 0 on the reading that a counter
|
||||||
|
// selling loose Mysore bananas is selling fresh fish, not frozen packs.
|
||||||
|
type rate struct {
|
||||||
|
productID int
|
||||||
|
name string
|
||||||
|
percent float64
|
||||||
|
why string
|
||||||
|
|
||||||
|
// apply gates the write. Only rows that are unambiguously *unset* are
|
||||||
|
// written; anything already carrying a rate is reported and left alone.
|
||||||
|
//
|
||||||
|
// The fresh produce at 1135 is the reason for this flag. Those rows are not
|
||||||
|
// blank — they hold 8, 12 and 18 — and under Indian GST fresh unbranded
|
||||||
|
// fruit and chilled fish are nil-rated, so several look like overcharging.
|
||||||
|
// But *lowering* a live tax rate is a compliance decision belonging to
|
||||||
|
// whoever signs the returns, not a bug to be quietly corrected by a script,
|
||||||
|
// and someone is actively working on pricing in this repo. Reported, not
|
||||||
|
// touched.
|
||||||
|
apply bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var rates = []rate{
|
||||||
|
// 1135 — fresh produce, nil-rated under Indian GST.
|
||||||
|
//
|
||||||
|
// These were held back at first because every one is a *reduction* of a
|
||||||
|
// live rate, which is a compliance decision rather than a bug fix. Released
|
||||||
|
// on the owner's explicit instruction after that was put to them.
|
||||||
|
//
|
||||||
|
// Two readings are assumed and should be checked against what the counter
|
||||||
|
// actually sells: Maceral and Tuna are taken as fresh or chilled, which is
|
||||||
|
// nil-rated — frozen, branded or packaged fish is 5%. Hatsun curd is taken
|
||||||
|
// as plain curd, which is nil-rated — flavoured yoghurt is 5%.
|
||||||
|
{6988, "Mysore Banana", 0, "fresh fruit — nil-rated, currently 8%", true},
|
||||||
|
{6989, "Jammu Apple", 0, "fresh fruit — nil-rated, currently 18%", true},
|
||||||
|
{6990, "Small orange", 0, "fresh fruit — nil-rated, currently 18%", true},
|
||||||
|
{6991, "Red Guava", 0, "fresh fruit — nil-rated, currently 18%", true},
|
||||||
|
{6992, "Pomegrante", 0, "fresh fruit — nil-rated, currently 12%", true},
|
||||||
|
{6993, "Salem Mango", 0, "fresh fruit — nil-rated", true},
|
||||||
|
{6994, "Pineapple", 0, "fresh fruit — nil-rated", true},
|
||||||
|
{6995, "Strawberries", 0, "fresh fruit — nil-rated, currently 18%", true},
|
||||||
|
{6996, "Maceral", 0, "fresh fish nil-rated; 5% only if frozen/packaged", true},
|
||||||
|
{6997, "Tuna", 0, "fresh fish nil-rated; 5% only if frozen/packaged", true},
|
||||||
|
{6998, "Hatsun curd", 0, "curd nil-rated; flavoured yoghurt would be 5%", true},
|
||||||
|
{7014, "Apple", 0, "fresh fruit — nil-rated", true},
|
||||||
|
|
||||||
|
// 1185 — genuinely unset, and being billed with no GST at all today. This
|
||||||
|
// is the half that is unambiguous: every one is an increase from zero, so
|
||||||
|
// nothing is being under-collected on the strength of a script's opinion.
|
||||||
|
{7074, "Amla Dabur Oral Care Chewing Gum 10g", 18, "chewing gum, 18%", true},
|
||||||
|
{7075, "Cheetos Chips 100g", 12, "packaged extruded snack, 12%", true},
|
||||||
|
{7076, "Cheerios Breakfast Cereal 100g", 18, "packaged cereal, 18%", true},
|
||||||
|
{7077, "Hot Heads 30g", 12, "packaged snack, 12%", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mode := "plan"
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
mode = os.Args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
write := mode == "apply"
|
||||||
|
|
||||||
|
fmt.Printf("%-6s %-38s %6s -> %6s %s\n", "id", "product", "now", "new", "why")
|
||||||
|
fmt.Println("-------------------------------------------------------------------------------------------")
|
||||||
|
|
||||||
|
undo := []string{}
|
||||||
|
changes := 0
|
||||||
|
review := 0
|
||||||
|
|
||||||
|
for _, r := range rates {
|
||||||
|
var current struct {
|
||||||
|
Taxpercent float64
|
||||||
|
Found bool
|
||||||
|
}
|
||||||
|
if err := db.Raw(`SELECT COALESCE(taxpercent, 0) AS taxpercent, true AS found
|
||||||
|
FROM products WHERE productid = ? LIMIT 1`,
|
||||||
|
r.productID).Scan(¤t).Error; err != nil {
|
||||||
|
log.Fatalf("reading %d: %v", r.productID, err)
|
||||||
|
}
|
||||||
|
if !current.Found {
|
||||||
|
fmt.Printf("%-6d %-38s NO products ROW - skipped\n", r.productID, r.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.Taxpercent == r.percent {
|
||||||
|
fmt.Printf("%-6d %-38s %6.0f unchanged %s\n",
|
||||||
|
r.productID, r.name, current.Taxpercent, r.why)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.apply {
|
||||||
|
fmt.Printf("%-6d %-38s %6.0f REVIEW %-3.0f %s\n",
|
||||||
|
r.productID, r.name, current.Taxpercent, r.percent, r.why)
|
||||||
|
review++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-6d %-38s %6.0f -> %6.0f %s\n",
|
||||||
|
r.productID, r.name, current.Taxpercent, r.percent, r.why)
|
||||||
|
|
||||||
|
undo = append(undo, fmt.Sprintf(
|
||||||
|
"UPDATE products SET taxpercent = %.0f WHERE productid = %d;",
|
||||||
|
current.Taxpercent, r.productID))
|
||||||
|
changes++
|
||||||
|
|
||||||
|
if write {
|
||||||
|
// updated is bumped so the catalogue delta carries the new rate to
|
||||||
|
// terminals holding a revision, rather than waiting for a full pull.
|
||||||
|
if err := db.Exec(`UPDATE products SET taxpercent = ?, updated = NOW()
|
||||||
|
WHERE productid = ?`, r.percent, r.productID).Error; err != nil {
|
||||||
|
log.Fatalf("writing %d: %v", r.productID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("-------------------------------------------------------------------------------------------")
|
||||||
|
if write {
|
||||||
|
fmt.Printf("APPLIED %d rate(s).\n", changes)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%d rate(s) would change. Nothing written — run `apply` to commit.\n", changes)
|
||||||
|
}
|
||||||
|
if review > 0 {
|
||||||
|
fmt.Printf("%d row(s) flagged REVIEW and deliberately not written — each is a\n"+
|
||||||
|
"reduction of a live tax rate and needs a decision, not a script.\n", review)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if len(undo) > 0 {
|
||||||
|
fmt.Println("-- undo:")
|
||||||
|
for _, u := range undo {
|
||||||
|
fmt.Println(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
scratch/healthproof/main.go
Normal file
141
scratch/healthproof/main.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Proof that the 30-second health heartbeat works end to end, over MQTT.
|
||||||
|
//
|
||||||
|
// Publishes a heartbeat to the live broker as a throwaway terminal, then polls
|
||||||
|
// the public API until it shows online — and keeps polling past the TTL so the
|
||||||
|
// automatic expiry is visible too. Nothing is written to Postgres; presence
|
||||||
|
// lives in Redis under a TTL and cleans itself up.
|
||||||
|
//
|
||||||
|
// REQUIRES pos_terminal CREDENTIALS. The MQTT_USER in .env is pos_ingest, which
|
||||||
|
// the broker ACL deliberately denies publish on the health topic — it may only
|
||||||
|
// write acks and the catalogue. Running this with the ingest account connects
|
||||||
|
// fine and then silently drops every publish, because Mosquitto answers an
|
||||||
|
// ACL-denied QoS 1 publish with a PUBACK and discards it. That looks exactly
|
||||||
|
// like a broken consumer and cost an hour of misdiagnosis; set MQTT_USER and
|
||||||
|
// MQTT_PASSWORD to the terminal account before believing a negative result.
|
||||||
|
//
|
||||||
|
// The HTTP path needs none of this — see POST /pos/health, which is what the
|
||||||
|
// fix on v1.3.96 added and how the endpoint was actually verified.
|
||||||
|
//
|
||||||
|
// go run ./scratch/healthproof
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
locationID = "1135"
|
||||||
|
terminalID = "TPROOF" // throwaway; disappears on its own when the TTL lapses
|
||||||
|
apiBase = "https://fiesta.nearle.app/live/api/v1/pos"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
brokerURL := os.Getenv("MQTT_URL")
|
||||||
|
if brokerURL == "" {
|
||||||
|
log.Fatal("MQTT_URL not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := mqtt.NewClientOptions().
|
||||||
|
AddBroker(brokerURL).
|
||||||
|
SetClientID("healthproof-" + terminalID).
|
||||||
|
SetUsername(os.Getenv("MQTT_USER")).
|
||||||
|
SetPassword(os.Getenv("MQTT_PASSWORD")).
|
||||||
|
SetConnectTimeout(10 * time.Second)
|
||||||
|
|
||||||
|
client := mqtt.NewClient(opts)
|
||||||
|
if t := client.Connect(); t.Wait() && t.Error() != nil {
|
||||||
|
log.Fatal("connect: ", t.Error())
|
||||||
|
}
|
||||||
|
defer client.Disconnect(250)
|
||||||
|
fmt.Printf("connected to %s as %s\n\n", brokerURL, terminalID)
|
||||||
|
|
||||||
|
fmt.Println("BEFORE — has this terminal ever been seen?")
|
||||||
|
show()
|
||||||
|
|
||||||
|
// Exactly what the till sends every 30 seconds.
|
||||||
|
health, _ := json.Marshal(map[string]any{
|
||||||
|
"schema": 1, "status": "online",
|
||||||
|
"terminal_id": terminalID, "location_id": locationID,
|
||||||
|
"store_name": "Ragul stores Selvapuram", "app_version": "1.1.0",
|
||||||
|
"pending_bills": 0, "pending_registrations": 0,
|
||||||
|
"today_bills": 17, "today_amount": 2510.0,
|
||||||
|
"printer_reachable": true,
|
||||||
|
"reported_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
|
||||||
|
topic := fmt.Sprintf("nearle/pos/%s/%s/health", locationID, terminalID)
|
||||||
|
if t := client.Publish(topic, 1, false, health); t.Wait() && t.Error() != nil {
|
||||||
|
log.Fatal("publish: ", t.Error())
|
||||||
|
}
|
||||||
|
fmt.Printf("\npublished one heartbeat to %s\n", topic)
|
||||||
|
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
fmt.Println("\nAFTER one heartbeat:")
|
||||||
|
show()
|
||||||
|
|
||||||
|
// The TTL is 90s and a real till refreshes every 30s, so it never lapses
|
||||||
|
// while the till is alive. Stopping here is what a till being switched off
|
||||||
|
// looks like.
|
||||||
|
fmt.Println("\nnow going quiet, as a till that was switched off would.")
|
||||||
|
fmt.Println("presence TTL is 90s, so it should drop off on its own:")
|
||||||
|
|
||||||
|
for _, wait := range []int{30, 30, 35} {
|
||||||
|
time.Sleep(time.Duration(wait) * time.Second)
|
||||||
|
fmt.Printf("\n+%ds since the last heartbeat:\n", wait)
|
||||||
|
show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func show() {
|
||||||
|
resp, err := http.Get(fmt.Sprintf("%s/health/location?location_id=%s", apiBase, locationID))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(" API unreachable:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Details struct {
|
||||||
|
Online int `json:"online"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Terminals []struct {
|
||||||
|
Terminalid string `json:"terminal_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Todaybills int `json:"today_bills"`
|
||||||
|
Todayamount float64 `json:"today_amount"`
|
||||||
|
Reportedat string `json:"reported_at"`
|
||||||
|
} `json:"terminals"`
|
||||||
|
} `json:"details"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
fmt.Println(" unparseable:", string(body)[:200])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" online %d of %d\n", out.Details.Online, out.Details.Total)
|
||||||
|
for _, t := range out.Details.Terminals {
|
||||||
|
mark := " "
|
||||||
|
if t.Terminalid == terminalID {
|
||||||
|
mark = ">"
|
||||||
|
}
|
||||||
|
extra := t.Reason
|
||||||
|
if t.Status == "online" {
|
||||||
|
extra = fmt.Sprintf("today %d bills / Rs %.0f, reported %s",
|
||||||
|
t.Todaybills, t.Todayamount, t.Reportedat)
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s %-8s %-8s %s\n", mark, t.Terminalid, t.Status, extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
scratch/liveloginproof/main.go
Normal file
130
scratch/liveloginproof/main.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// Proves sign-in end to end against the deployed API.
|
||||||
|
//
|
||||||
|
// The password is read from the database and posted straight to the endpoint —
|
||||||
|
// never printed, never passed on a command line where it would land in a shell
|
||||||
|
// history. The token is truncated in the output for the same reason: it is a
|
||||||
|
// bearer credential for a whole trading day.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const base = "https://fiesta.nearle.app/live/api/v1/pos"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
who := "rsselvapuram@gmail.com"
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
who = os.Args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = 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 pw string
|
||||||
|
db.Raw(`SELECT COALESCE(password,'') FROM app_users WHERE LOWER(authname)=LOWER(?) LIMIT 1`, who).Scan(&pw)
|
||||||
|
if pw == "" {
|
||||||
|
log.Fatalf("%s has no password set", who)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"authname": who, "password": pw, "terminal_id": "PROBE", "device_id": "probe-device",
|
||||||
|
})
|
||||||
|
resp, err := http.Post(base+"/login", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
fmt.Printf("POST /login HTTP %d\n", resp.StatusCode)
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Details struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Expiresat string `json:"expires_at"`
|
||||||
|
Tenantid int `json:"tenant_id"`
|
||||||
|
Tenantname string `json:"tenant_name"`
|
||||||
|
Storeid string `json:"store_id"`
|
||||||
|
Locationname string `json:"location_name"`
|
||||||
|
Gstin string `json:"gstin"`
|
||||||
|
Locations []struct {
|
||||||
|
Locationid int `json:"location_id"`
|
||||||
|
Locationname string `json:"location_name"`
|
||||||
|
} `json:"locations"`
|
||||||
|
Staff []struct {
|
||||||
|
Fullname string `json:"full_name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
} `json:"staff"`
|
||||||
|
} `json:"details"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
fmt.Println(string(raw))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
fmt.Println(" ", out.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t := out.Details.Token
|
||||||
|
fmt.Printf(" token %s… (%d chars, signature verified below)\n", t[:12], len(t))
|
||||||
|
fmt.Printf(" expires %s\n", out.Details.Expiresat)
|
||||||
|
fmt.Printf(" tenant %d %s\n", out.Details.Tenantid, out.Details.Tenantname)
|
||||||
|
fmt.Printf(" store_id %s (%s)\n", out.Details.Storeid, out.Details.Locationname)
|
||||||
|
fmt.Printf(" gstin %s\n", out.Details.Gstin)
|
||||||
|
fmt.Printf(" outlets %d\n", len(out.Details.Locations))
|
||||||
|
fmt.Printf(" staff %d\n", len(out.Details.Staff))
|
||||||
|
for _, s := range out.Details.Staff {
|
||||||
|
fmt.Printf(" %s (%s)\n", s.Fullname, s.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token has to actually open the doors it claims to.
|
||||||
|
for _, path := range []string{"/session", "/staff", "/catalogue?store_id=" + out.Details.Storeid + "&page_size=1"} {
|
||||||
|
req, _ := http.NewRequest("GET", base+path, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+t)
|
||||||
|
r, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
r.Body.Close()
|
||||||
|
fmt.Printf("\nGET %-28s HTTP %d %s", strings.Split(path, "&")[0], r.StatusCode, truncate(string(b), 150))
|
||||||
|
}
|
||||||
|
|
||||||
|
// And must NOT open somebody else's.
|
||||||
|
req, _ := http.NewRequest("GET", base+"/catalogue?store_id=1185&page_size=1", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+t)
|
||||||
|
r, _ := http.DefaultClient.Do(req)
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
r.Body.Close()
|
||||||
|
fmt.Printf("\n\nGET /catalogue (ANOTHER TENANT'S OUTLET 1185) HTTP %d %s\n",
|
||||||
|
r.StatusCode, truncate(string(b), 160))
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
s = strings.ReplaceAll(s, "\n", " ")
|
||||||
|
if len(s) > n {
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
92
scratch/loginreach/main.go
Normal file
92
scratch/loginreach/main.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Who can actually open a till, across the whole platform.
|
||||||
|
//
|
||||||
|
// Read-only. Answers the question the account model raises the moment sign-in
|
||||||
|
// becomes real: the endpoint is open to every tenant, so *which* of them can
|
||||||
|
// genuinely reach it, and does anyone reach it who should not.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The exact predicate posLoginCandidates + PosLogin apply.
|
||||||
|
eligible := `
|
||||||
|
FROM app_users a
|
||||||
|
WHERE LOWER(COALESCE(a.status,'active')) <> 'inactive'
|
||||||
|
AND COALESCE(a.password,'') <> ''
|
||||||
|
AND COALESCE(a.authname,'') <> ''
|
||||||
|
AND COALESCE(a.tenantid,0) > 0
|
||||||
|
AND EXISTS (SELECT 1 FROM tenantlocations l
|
||||||
|
WHERE l.tenantid = a.tenantid
|
||||||
|
AND LOWER(COALESCE(l.status,'active')) <> 'inactive'
|
||||||
|
AND (COALESCE(a.locationid,0) = 0 OR l.locationid = a.locationid))`
|
||||||
|
|
||||||
|
var total, tenants int
|
||||||
|
db.Raw(`SELECT COUNT(*) ` + eligible).Scan(&total)
|
||||||
|
db.Raw(`SELECT COUNT(DISTINCT a.tenantid) ` + eligible).Scan(&tenants)
|
||||||
|
|
||||||
|
var allUsers, allTenants int
|
||||||
|
db.Raw(`SELECT COUNT(*) FROM app_users`).Scan(&allUsers)
|
||||||
|
db.Raw(`SELECT COUNT(*) FROM tenants`).Scan(&allTenants)
|
||||||
|
|
||||||
|
fmt.Printf("app_users rows %d\n", allUsers)
|
||||||
|
fmt.Printf(" can open a till %d\n", total)
|
||||||
|
fmt.Printf("tenants %d\n", allTenants)
|
||||||
|
fmt.Printf(" with a usable login %d\n\n", tenants)
|
||||||
|
|
||||||
|
// Which tenants, and whether they have a catalogue to sell.
|
||||||
|
var rows []struct {
|
||||||
|
Tenantid int
|
||||||
|
Tenantname string
|
||||||
|
Users int
|
||||||
|
Outlets int
|
||||||
|
Products int
|
||||||
|
}
|
||||||
|
db.Raw(`
|
||||||
|
SELECT t.tenantid, COALESCE(t.tenantname,'') AS tenantname,
|
||||||
|
(SELECT COUNT(*) FROM app_users a WHERE a.tenantid=t.tenantid
|
||||||
|
AND LOWER(COALESCE(a.status,'active'))<>'inactive'
|
||||||
|
AND COALESCE(a.password,'')<>'' AND COALESCE(a.authname,'')<>'') AS users,
|
||||||
|
(SELECT COUNT(*) FROM tenantlocations l WHERE l.tenantid=t.tenantid
|
||||||
|
AND LOWER(COALESCE(l.status,'active'))<>'inactive') AS outlets,
|
||||||
|
(SELECT COUNT(*) FROM productlocations p WHERE p.tenantid=t.tenantid) AS products
|
||||||
|
FROM tenants t
|
||||||
|
WHERE EXISTS (SELECT 1 FROM app_users a WHERE a.tenantid=t.tenantid
|
||||||
|
AND LOWER(COALESCE(a.status,'active'))<>'inactive'
|
||||||
|
AND COALESCE(a.password,'')<>'' AND COALESCE(a.authname,'')<>'')
|
||||||
|
ORDER BY products DESC, t.tenantid`).Scan(&rows)
|
||||||
|
|
||||||
|
fmt.Printf("%-8s %-34s %6s %8s %9s\n", "tenant", "name", "logins", "outlets", "products")
|
||||||
|
fmt.Println("---------------------------------------------------------------------------")
|
||||||
|
for _, r := range rows {
|
||||||
|
fmt.Printf("%-8d %-34s %6d %8d %9d\n", r.Tenantid, r.Tenantname, r.Users, r.Outlets, r.Products)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles. The POS login does not check one, so this says who slips through.
|
||||||
|
var roles []struct {
|
||||||
|
Roleid int
|
||||||
|
C int
|
||||||
|
}
|
||||||
|
db.Raw(`SELECT COALESCE(a.roleid,0) AS roleid, COUNT(*) AS c ` + eligible + ` GROUP BY 1 ORDER BY c DESC`).Scan(&roles)
|
||||||
|
fmt.Println("\neligible logins by roleid:")
|
||||||
|
for _, r := range roles {
|
||||||
|
fmt.Printf(" role %-4d %d\n", r.Roleid, r.C)
|
||||||
|
}
|
||||||
|
}
|
||||||
241
scratch/posloginproof/main.go
Normal file
241
scratch/posloginproof/main.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
// Proves POS sign-in against the live database, read-only.
|
||||||
|
//
|
||||||
|
// Written because the interesting half of this feature is not the token — that
|
||||||
|
// has unit tests — but whether the *account model* actually holds up against
|
||||||
|
// real rows: does a shop's user resolve to the right tenant, does the outlet
|
||||||
|
// list come back non-empty, and does an account from one tenant get refused at
|
||||||
|
// another tenant's outlet.
|
||||||
|
//
|
||||||
|
// Passwords are read out of the database and handed straight back into the
|
||||||
|
// login so the happy path can be proven without anyone typing or printing one.
|
||||||
|
// Nothing here is ever echoed.
|
||||||
|
//
|
||||||
|
// go run ./scratch/posloginproof users 1087 # who could open a till
|
||||||
|
// go run ./scratch/posloginproof login 1087 # sign the first one in
|
||||||
|
// go run ./scratch/posloginproof cross # refuse another tenant's outlet
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/repositories"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mode := "users"
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
mode = os.Args[1]
|
||||||
|
}
|
||||||
|
tenantID := 1087
|
||||||
|
if len(os.Args) > 2 {
|
||||||
|
tenantID, _ = strconv.Atoi(os.Args[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = godotenv.Load()
|
||||||
|
if strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET")) == "" {
|
||||||
|
// Only needed by the service layer; the repository probes below work
|
||||||
|
// without it. Set a throwaway so `login` can mint.
|
||||||
|
os.Setenv("POS_TOKEN_SECRET", "scratch-proof-signing-key-not-real")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := repositories.NewPosRepository(db)
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case "users":
|
||||||
|
listUsers(db, tenantID)
|
||||||
|
case "login":
|
||||||
|
proveLogin(db, repo, tenantID)
|
||||||
|
case "cross":
|
||||||
|
proveCrossTenantRefusal(db, repo)
|
||||||
|
default:
|
||||||
|
log.Fatalf("unknown mode %q", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listUsers shows who could open a till for a tenant, and whether their record
|
||||||
|
// is complete enough to do it.
|
||||||
|
func listUsers(db *gorm.DB, tenantID int) {
|
||||||
|
type row struct {
|
||||||
|
Userid int
|
||||||
|
Authname string
|
||||||
|
Roleid int
|
||||||
|
Status string
|
||||||
|
Locationid int
|
||||||
|
Haspw bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []row
|
||||||
|
err := db.Raw(`
|
||||||
|
SELECT userid, COALESCE(authname,'') AS authname, COALESCE(roleid,0) AS roleid,
|
||||||
|
COALESCE(status,'') AS status, COALESCE(locationid,0) AS locationid,
|
||||||
|
(COALESCE(password,'') <> '') AS haspw
|
||||||
|
FROM app_users WHERE tenantid = ? ORDER BY userid`, tenantID).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("tenant %d — %d user(s)\n", tenantID, len(rows))
|
||||||
|
fmt.Printf("%-8s %-34s %-6s %-10s %-10s %s\n", "userid", "authname", "role", "status", "location", "password set")
|
||||||
|
fmt.Println(strings.Repeat("-", 92))
|
||||||
|
for _, r := range rows {
|
||||||
|
loc := "any"
|
||||||
|
if r.Locationid > 0 {
|
||||||
|
loc = strconv.Itoa(r.Locationid)
|
||||||
|
}
|
||||||
|
fmt.Printf("%-8d %-34s %-6d %-10s %-10s %v\n",
|
||||||
|
r.Userid, r.Authname, r.Roleid, r.Status, loc, r.Haspw)
|
||||||
|
}
|
||||||
|
|
||||||
|
var locs []struct {
|
||||||
|
Locationid int
|
||||||
|
Locationname string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
db.Raw(`SELECT locationid, COALESCE(locationname,'') AS locationname,
|
||||||
|
COALESCE(status,'') AS status
|
||||||
|
FROM tenantlocations WHERE tenantid = ? ORDER BY locationid`, tenantID).Scan(&locs)
|
||||||
|
|
||||||
|
fmt.Printf("\noutlets: %d\n", len(locs))
|
||||||
|
for _, l := range locs {
|
||||||
|
fmt.Printf(" %-8d %-40s %s\n", l.Locationid, l.Locationname, l.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// proveLogin signs in the tenant's first usable account and reports what the
|
||||||
|
// session resolved to.
|
||||||
|
func proveLogin(db *gorm.DB, repo repositories.PosRepository, tenantID int) {
|
||||||
|
// An explicit account, when the interesting case is a particular one — the
|
||||||
|
// till's own outlet, or a proprietor who reaches several.
|
||||||
|
wanted := ""
|
||||||
|
if len(os.Args) > 3 {
|
||||||
|
wanted = strings.TrimSpace(os.Args[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
var cred struct {
|
||||||
|
Authname string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
SELECT COALESCE(authname,'') AS authname, COALESCE(password,'') AS password
|
||||||
|
FROM app_users
|
||||||
|
WHERE tenantid = ? AND COALESCE(password,'') <> '' AND COALESCE(authname,'') <> ''
|
||||||
|
AND LOWER(COALESCE(status,'active')) <> 'inactive'`
|
||||||
|
params := []interface{}{tenantID}
|
||||||
|
if wanted != "" {
|
||||||
|
query += ` AND LOWER(authname) = LOWER(?)`
|
||||||
|
params = append(params, wanted)
|
||||||
|
}
|
||||||
|
query += ` ORDER BY userid LIMIT 1`
|
||||||
|
|
||||||
|
err := db.Raw(query, params...).Scan(&cred).Error
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if cred.Authname == "" {
|
||||||
|
log.Fatalf("tenant %d has no active account with a password set", tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("signing in %s (password read from the database, not printed)\n\n", cred.Authname)
|
||||||
|
|
||||||
|
session, err := repo.PosLogin(models.PosLoginRequest{
|
||||||
|
Authname: cred.Authname,
|
||||||
|
Password: cred.Password,
|
||||||
|
Terminalid: "T5EDD",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("REFUSED: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" user %d %s\n", session.Userid, session.Fullname)
|
||||||
|
fmt.Printf(" tenant %d %s\n", session.Tenantid, session.Tenantname)
|
||||||
|
fmt.Printf(" store_id %s\n", session.Storeid)
|
||||||
|
fmt.Printf(" outlet %d %s\n", session.Locationid, session.Locationname)
|
||||||
|
fmt.Printf(" gstin %s\n", session.Gstin)
|
||||||
|
fmt.Printf(" outlets %d reachable\n", len(session.Locations))
|
||||||
|
for _, l := range session.Locations {
|
||||||
|
fmt.Printf(" %-8d %s\n", l.Locationid, l.Locationname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The check that matters: a wrong password must be refused, and refused
|
||||||
|
// with the same message a wrong email gets.
|
||||||
|
if _, err := repo.PosLogin(models.PosLoginRequest{
|
||||||
|
Authname: cred.Authname, Password: cred.Password + "x",
|
||||||
|
}); err == nil {
|
||||||
|
fmt.Println("\n !! a wrong password was ACCEPTED")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("\n wrong password refused: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.PosLogin(models.PosLoginRequest{
|
||||||
|
Authname: "nobody@nowhere.invalid", Password: "whatever",
|
||||||
|
}); err != nil {
|
||||||
|
fmt.Printf(" unknown account refused: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// proveCrossTenantRefusal is the authorisation test: an account from one tenant
|
||||||
|
// must not be able to open a till at another tenant's outlet, which is exactly
|
||||||
|
// what a till could do before by editing one field in Settings.
|
||||||
|
func proveCrossTenantRefusal(db *gorm.DB, repo repositories.PosRepository) {
|
||||||
|
var cred struct {
|
||||||
|
Authname string
|
||||||
|
Password string
|
||||||
|
Tenantid int
|
||||||
|
}
|
||||||
|
err := db.Raw(`
|
||||||
|
SELECT COALESCE(a.authname,'') AS authname, COALESCE(a.password,'') AS password, a.tenantid
|
||||||
|
FROM app_users a
|
||||||
|
WHERE COALESCE(a.password,'') <> '' AND COALESCE(a.authname,'') <> '' AND a.tenantid > 0
|
||||||
|
AND LOWER(COALESCE(a.status,'active')) <> 'inactive'
|
||||||
|
ORDER BY a.userid LIMIT 1`).Scan(&cred).Error
|
||||||
|
if err != nil || cred.Authname == "" {
|
||||||
|
log.Fatalf("no usable account to test with: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody else's outlet.
|
||||||
|
var foreign int
|
||||||
|
db.Raw(`SELECT locationid FROM tenantlocations WHERE tenantid <> ? ORDER BY locationid LIMIT 1`,
|
||||||
|
cred.Tenantid).Scan(&foreign)
|
||||||
|
if foreign == 0 {
|
||||||
|
log.Fatal("only one tenant has outlets; nothing to cross")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("account belongs to tenant %d; asking for outlet %d, which does not\n\n",
|
||||||
|
cred.Tenantid, foreign)
|
||||||
|
|
||||||
|
if _, err := repo.PosLogin(models.PosLoginRequest{
|
||||||
|
Authname: cred.Authname, Password: cred.Password, Locationid: foreign,
|
||||||
|
}); err == nil {
|
||||||
|
fmt.Println(" !! ACCEPTED — a tenant signed a till into another tenant's outlet")
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" refused: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := repo.PosLocationAllowed(cred.Tenantid, foreign)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" PosLocationAllowed(tenant %d, outlet %d) = %v (want false)\n",
|
||||||
|
cred.Tenantid, foreign, allowed)
|
||||||
|
}
|
||||||
214
scratch/poslogins/main.go
Normal file
214
scratch/poslogins/main.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
// Reports who can actually sign in at an outlet, and by which of the two ways.
|
||||||
|
//
|
||||||
|
// Read-only. Answers the question a shop asks on day one — "what do I type into
|
||||||
|
// the till?" — by separating the two credentials that exist, because they are
|
||||||
|
// not interchangeable:
|
||||||
|
//
|
||||||
|
// - a password opens a *closed* terminal, and only a supervisor's does
|
||||||
|
// anything useful, because the shell it opens is decided by the account;
|
||||||
|
//
|
||||||
|
// - a PIN switches operator on a terminal that is *already open*, and is
|
||||||
|
// useless on its own.
|
||||||
|
//
|
||||||
|
// go run ./scratch/poslogins 1087 1135
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
Userid int
|
||||||
|
Fullname, Authname, Contactno string
|
||||||
|
Password string
|
||||||
|
Pin int64
|
||||||
|
Roleid, Configid int
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
tenantID, locationID := 1087, 1135
|
||||||
|
if len(os.Args) > 2 {
|
||||||
|
tenantID, _ = strconv.Atoi(os.Args[1])
|
||||||
|
locationID, _ = strconv.Atoi(os.Args[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `top` ranks outlets by what a till would actually have to sell, so a demo
|
||||||
|
// is pointed at a shop with a catalogue rather than at one that opens empty.
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "top" {
|
||||||
|
type outletRow struct {
|
||||||
|
Tenantid, Locationid int
|
||||||
|
Tenantname, Locname string
|
||||||
|
Products, Withpasswd int
|
||||||
|
}
|
||||||
|
var top []outletRow
|
||||||
|
db.Raw(`
|
||||||
|
SELECT t.tenantid, l.locationid,
|
||||||
|
COALESCE(t.tenantname,'') AS tenantname,
|
||||||
|
COALESCE(l.locationname,'') AS locname,
|
||||||
|
COUNT(DISTINCT a.productid) AS products,
|
||||||
|
(SELECT COUNT(*) FROM app_users u
|
||||||
|
WHERE u.tenantid = t.tenantid
|
||||||
|
AND COALESCE(u.locationid,0) IN (l.locationid, 0)
|
||||||
|
AND COALESCE(u.password,'') <> ''
|
||||||
|
AND LOWER(COALESCE(u.status,'')) <> 'inactive') AS withpasswd
|
||||||
|
FROM tenantlocations l
|
||||||
|
JOIN tenants t ON t.tenantid = l.tenantid
|
||||||
|
JOIN productlocations b ON b.locationid = l.locationid AND b.tenantid = l.tenantid
|
||||||
|
JOIN products a ON a.productid = b.productid AND a.tenantid = b.tenantid
|
||||||
|
GROUP BY t.tenantid, l.locationid, t.tenantname, l.locationname
|
||||||
|
ORDER BY products DESC LIMIT 400`).Scan(&top)
|
||||||
|
|
||||||
|
fmt.Printf("%-8s %-10s %-22s %-26s %-9s %s\n",
|
||||||
|
"tenant", "outlet", "tenant name", "outlet name", "products", "can sign in")
|
||||||
|
shown := 0
|
||||||
|
for _, o := range top {
|
||||||
|
// Only outlets a person can actually open. A big catalogue behind a
|
||||||
|
// till nobody can sign in to is not a candidate for anything.
|
||||||
|
if o.Withpasswd == 0 || shown >= 12 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shown++
|
||||||
|
fmt.Printf("%-8d %-10d %-22s %-26s %-9d %d\n",
|
||||||
|
o.Tenantid, o.Locationid, trunc(o.Tenantname, 22),
|
||||||
|
trunc(o.Locname, 26), o.Products, o.Withpasswd)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenant, outlet string
|
||||||
|
db.Raw(`SELECT COALESCE(tenantname,'') FROM tenants WHERE tenantid=?`, tenantID).Scan(&tenant)
|
||||||
|
db.Raw(`SELECT COALESCE(locationname,'') FROM tenantlocations WHERE locationid=? AND tenantid=?`,
|
||||||
|
locationID, tenantID).Scan(&outlet)
|
||||||
|
if outlet == "" {
|
||||||
|
log.Fatalf("tenant %d has no outlet %d", tenantID, locationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counted the way /pos/catalogue counts them — products joined to this
|
||||||
|
// outlet — rather than per tenant. A tenant with a full catalogue can still
|
||||||
|
// have an outlet that stocks none of it, and that outlet's till opens empty.
|
||||||
|
var products int64
|
||||||
|
db.Raw(`SELECT COUNT(*)
|
||||||
|
FROM products a
|
||||||
|
INNER JOIN productlocations b
|
||||||
|
ON a.productid = b.productid AND a.tenantid = b.tenantid
|
||||||
|
WHERE a.tenantid = ? AND b.locationid = ?`, tenantID, locationID).Scan(&products)
|
||||||
|
|
||||||
|
fmt.Printf("tenant %d %s\noutlet %d %s\nproducts stocked at this outlet: %d\n",
|
||||||
|
tenantID, tenant, locationID, outlet, products)
|
||||||
|
|
||||||
|
// Everyone the outlet can see. locationid 0 is a tenant-wide account — a
|
||||||
|
// proprietor who is not pinned to one shop — and those can open any of
|
||||||
|
// their outlets, so they belong in this list too.
|
||||||
|
var rows []row
|
||||||
|
db.Raw(`
|
||||||
|
SELECT userid,
|
||||||
|
TRIM(COALESCE(firstname,'') || ' ' || COALESCE(lastname,'')) AS fullname,
|
||||||
|
COALESCE(authname,'') AS authname,
|
||||||
|
COALESCE(contactno,'') AS contactno,
|
||||||
|
COALESCE(password,'') AS password,
|
||||||
|
COALESCE(pin,0) AS pin,
|
||||||
|
COALESCE(roleid,0) AS roleid,
|
||||||
|
COALESCE(configid,0) AS configid,
|
||||||
|
COALESCE(status,'') AS status
|
||||||
|
FROM app_users
|
||||||
|
WHERE tenantid = ?
|
||||||
|
AND COALESCE(locationid,0) IN (?, 0)
|
||||||
|
AND LOWER(COALESCE(status,'')) <> 'inactive'
|
||||||
|
ORDER BY userid`, tenantID, locationID).Scan(&rows)
|
||||||
|
|
||||||
|
fmt.Printf("\n=== PASSWORD SIGN-IN (POST /v1/pos/login) — opens a closed terminal ===\n")
|
||||||
|
fmt.Printf("%-7s %-24s %-30s %-12s %-6s %s\n",
|
||||||
|
"userid", "name", "authname (the username)", "role", "shell", "password")
|
||||||
|
any := false
|
||||||
|
for _, r := range rows {
|
||||||
|
if strings.TrimSpace(r.Password) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
any = true
|
||||||
|
shell := "cashier"
|
||||||
|
if models.PosRoleCanManageStaff(r.Roleid) {
|
||||||
|
shell = "SUPER"
|
||||||
|
}
|
||||||
|
role := models.PosRoleName(r.Roleid)
|
||||||
|
if role == "" {
|
||||||
|
role = fmt.Sprintf("(roleid %d)", r.Roleid)
|
||||||
|
}
|
||||||
|
id := r.Authname
|
||||||
|
if id == "" {
|
||||||
|
id = r.Contactno + " (phone)"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-7d %-24s %-30s %-12s %-6s %s\n",
|
||||||
|
r.Userid, trunc(r.Fullname, 24), trunc(id, 30), role, shell, r.Password)
|
||||||
|
}
|
||||||
|
if !any {
|
||||||
|
fmt.Println(" (nobody at this outlet has a password — the till cannot be opened)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n=== PIN SIGN-IN (POST /v1/pos/login/pin) — switches operator, terminal already open ===\n")
|
||||||
|
fmt.Printf("%-7s %-24s %-12s %-6s %s\n", "userid", "name", "role", "shell", "pin")
|
||||||
|
any = false
|
||||||
|
seen := map[int64]int{}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Pin == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[r.Pin]++
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Pin == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
any = true
|
||||||
|
shell := "cashier"
|
||||||
|
if models.PosRoleCanManageStaff(r.Roleid) {
|
||||||
|
shell = "SUPER"
|
||||||
|
}
|
||||||
|
role := models.PosRoleName(r.Roleid)
|
||||||
|
if role == "" {
|
||||||
|
role = fmt.Sprintf("(roleid %d)", r.Roleid)
|
||||||
|
}
|
||||||
|
note := ""
|
||||||
|
if seen[r.Pin] > 1 {
|
||||||
|
note = " <- DUPLICATE, refused at sign-in"
|
||||||
|
}
|
||||||
|
if r.Pin < 1000 {
|
||||||
|
note = " <- under 4 digits, cannot be typed"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-7d %-24s %-12s %-6s %04d%s\n",
|
||||||
|
r.Userid, trunc(r.Fullname, 24), role, shell, r.Pin, note)
|
||||||
|
}
|
||||||
|
if !any {
|
||||||
|
fmt.Println(" (nobody at this outlet has a PIN)")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
func trunc(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n-1] + "…"
|
||||||
|
}
|
||||||
102
scratch/posroles/main.go
Normal file
102
scratch/posroles/main.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Adds the two POS roles to app_roles.
|
||||||
|
//
|
||||||
|
// `app_roles` has no sequence on roleid — every id in it was assigned by hand —
|
||||||
|
// so 7 and 8 are written explicitly and must match models.PosRoleSupervisor and
|
||||||
|
// models.PosRoleCashier.
|
||||||
|
//
|
||||||
|
// configid is left NULL deliberately. Every other row is portal-specific, which
|
||||||
|
// is why Admin appears twice (3 and 5) and Manager twice (4 and 6). A till is a
|
||||||
|
// till whichever portal a tenant uses, and duplicating these per config would
|
||||||
|
// be one more thing to remember on every onboarding.
|
||||||
|
//
|
||||||
|
// go run ./scratch/posroles plan
|
||||||
|
// go run ./scratch/posroles apply
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mode := "plan"
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
mode = os.Args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
wanted := []struct {
|
||||||
|
id int
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{models.PosRoleSupervisor, "Supervisor"},
|
||||||
|
{models.PosRoleCashier, "Cashier"},
|
||||||
|
}
|
||||||
|
|
||||||
|
write := mode == "apply"
|
||||||
|
changes := 0
|
||||||
|
|
||||||
|
for _, w := range wanted {
|
||||||
|
var existing string
|
||||||
|
db.Raw(`SELECT COALESCE(rolename,'') FROM app_roles WHERE roleid = ?`, w.id).Scan(&existing)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case existing == w.name:
|
||||||
|
fmt.Printf(" %-4d %-12s already present\n", w.id, w.name)
|
||||||
|
case existing != "":
|
||||||
|
// Refuses rather than overwrites. Renaming a role that something
|
||||||
|
// else already points at would silently re-permission real accounts.
|
||||||
|
fmt.Printf(" %-4d OCCUPIED by %q — refusing to overwrite\n", w.id, existing)
|
||||||
|
default:
|
||||||
|
fmt.Printf(" %-4d %-12s WOULD INSERT\n", w.id, w.name)
|
||||||
|
changes++
|
||||||
|
if write {
|
||||||
|
if err := db.Exec(
|
||||||
|
`INSERT INTO app_roles (roleid, rolename, configid) VALUES (?, ?, NULL)`,
|
||||||
|
w.id, w.name,
|
||||||
|
).Error; err != nil {
|
||||||
|
log.Fatalf("inserting role %d: %v", w.id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
if write {
|
||||||
|
fmt.Printf("APPLIED %d role(s).\n", changes)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%d role(s) would be added. Nothing written — run `apply`.\n", changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []struct {
|
||||||
|
Roleid int
|
||||||
|
Rolename string
|
||||||
|
}
|
||||||
|
db.Raw(`SELECT roleid, COALESCE(rolename,'') AS rolename FROM app_roles ORDER BY roleid`).Scan(&rows)
|
||||||
|
fmt.Println("\napp_roles now:")
|
||||||
|
for _, r := range rows {
|
||||||
|
fmt.Printf(" %-4d %s\n", r.Roleid, r.Rolename)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) > 0 {
|
||||||
|
fmt.Println("\n-- undo:")
|
||||||
|
fmt.Printf("DELETE FROM app_roles WHERE roleid IN (%d, %d);\n",
|
||||||
|
models.PosRoleSupervisor, models.PosRoleCashier)
|
||||||
|
}
|
||||||
|
}
|
||||||
179
scratch/posseparation/main.go
Normal file
179
scratch/posseparation/main.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
// Proves the till and the Nearle Daily application no longer share accounts.
|
||||||
|
//
|
||||||
|
// Read-only. Four claims, each checked against live rows rather than asserted:
|
||||||
|
//
|
||||||
|
// 1. a provisioned supervisor can open a closed terminal;
|
||||||
|
//
|
||||||
|
// 2. a back-office account cannot, however senior it is;
|
||||||
|
//
|
||||||
|
// 3. a till account cannot reach the Nearle Daily application; and
|
||||||
|
//
|
||||||
|
// 4. a till account is not listed as though it were an app user.
|
||||||
|
//
|
||||||
|
// go run ./scratch/posseparation
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/repositories"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var failures int
|
||||||
|
|
||||||
|
func check(claim string, ok bool, detail string) {
|
||||||
|
mark := "PASS"
|
||||||
|
if !ok {
|
||||||
|
mark = "FAIL"
|
||||||
|
failures++
|
||||||
|
}
|
||||||
|
fmt.Printf(" [%s] %s\n %s\n", mark, claim, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
pos := repositories.NewPosRepository(db)
|
||||||
|
users := repositories.NewUserRepository(db)
|
||||||
|
|
||||||
|
// A real provisioned supervisor, and its password, read back out.
|
||||||
|
var sup struct {
|
||||||
|
Userid int
|
||||||
|
Authname, Password string
|
||||||
|
Configid, Tenantid, Location int
|
||||||
|
}
|
||||||
|
db.Raw(`SELECT userid, COALESCE(authname,'') authname, COALESCE(password,'') password,
|
||||||
|
COALESCE(configid,0) configid, COALESCE(tenantid,0) tenantid,
|
||||||
|
COALESCE(locationid,0) location
|
||||||
|
FROM app_users
|
||||||
|
WHERE COALESCE(roleid,0) = ? AND COALESCE(authname,'') <> ''
|
||||||
|
ORDER BY userid LIMIT 1`, models.PosRoleSupervisor).Scan(&sup)
|
||||||
|
if sup.Userid == 0 {
|
||||||
|
fmt.Println("no provisioned supervisor to test with")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("supervisor under test: %d %s (tenant %d, outlet %d)\n\n",
|
||||||
|
sup.Userid, sup.Authname, sup.Tenantid, sup.Location)
|
||||||
|
|
||||||
|
fmt.Println("1. a provisioned supervisor opens a closed terminal")
|
||||||
|
session, err := pos.PosLogin(models.PosLoginRequest{
|
||||||
|
Authname: sup.Authname, Password: sup.Password,
|
||||||
|
})
|
||||||
|
check("supervisor signs in at the till",
|
||||||
|
err == nil && session != nil,
|
||||||
|
fmt.Sprintf("err=%v", err))
|
||||||
|
if session != nil {
|
||||||
|
check("and gets the supervisor shell",
|
||||||
|
session.Canmanagestaff && session.Roleid == models.PosRoleSupervisor,
|
||||||
|
fmt.Sprintf("role=%s can_manage_staff=%v", session.Role, session.Canmanagestaff))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same, for a cashier. A cashier opening a till on their own credentials
|
||||||
|
// is the point of this: a shop should not need two people present before it
|
||||||
|
// can sell anything.
|
||||||
|
var cash struct {
|
||||||
|
Userid int
|
||||||
|
Authname, Password string
|
||||||
|
}
|
||||||
|
db.Raw(`SELECT userid, COALESCE(authname,'') authname, COALESCE(password,'') password
|
||||||
|
FROM app_users
|
||||||
|
WHERE COALESCE(roleid,0) = ? AND COALESCE(authname,'') <> ''
|
||||||
|
ORDER BY userid LIMIT 1`, models.PosRoleCashier).Scan(&cash)
|
||||||
|
|
||||||
|
if cash.Userid == 0 {
|
||||||
|
check("a cashier has their own login", false, "no cashier has an authname")
|
||||||
|
} else {
|
||||||
|
cs, err := pos.PosLogin(models.PosLoginRequest{
|
||||||
|
Authname: cash.Authname, Password: cash.Password,
|
||||||
|
})
|
||||||
|
check(fmt.Sprintf("cashier %s opens a closed terminal alone", cash.Authname),
|
||||||
|
err == nil && cs != nil,
|
||||||
|
fmt.Sprintf("err=%v", err))
|
||||||
|
if cs != nil {
|
||||||
|
check("and is held to the billing-only shell",
|
||||||
|
!cs.Canmanagestaff && cs.Roleid == models.PosRoleCashier,
|
||||||
|
fmt.Sprintf("role=%s can_manage_staff=%v", cs.Role, cs.Canmanagestaff))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n2. back-office accounts cannot open a terminal at all")
|
||||||
|
var backOffice []struct {
|
||||||
|
Userid int
|
||||||
|
Authname, Password string
|
||||||
|
Roleid int
|
||||||
|
}
|
||||||
|
db.Raw(`SELECT userid, COALESCE(authname,'') authname, COALESCE(password,'') password,
|
||||||
|
COALESCE(roleid,0) roleid
|
||||||
|
FROM app_users
|
||||||
|
WHERE COALESCE(roleid,0) IN (1,2,3,4,5,6)
|
||||||
|
AND COALESCE(authname,'') <> '' AND COALESCE(password,'') <> ''
|
||||||
|
AND LOWER(COALESCE(status,'active')) <> 'inactive'
|
||||||
|
ORDER BY userid LIMIT 5`).Scan(&backOffice)
|
||||||
|
for _, b := range backOffice {
|
||||||
|
_, err := pos.PosLogin(models.PosLoginRequest{
|
||||||
|
Authname: b.Authname, Password: b.Password,
|
||||||
|
})
|
||||||
|
check(fmt.Sprintf("roleid %d (%s) refused at the till", b.Roleid, b.Authname),
|
||||||
|
err != nil && strings.Contains(err.Error(), "not set up for the till"),
|
||||||
|
fmt.Sprintf("err=%v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n3. a till account cannot reach the Nearle Daily application")
|
||||||
|
uid, _, _ := users.GetUserByAuthname(sup.Authname, sup.Configid)
|
||||||
|
check("applogin lookup does not find the supervisor",
|
||||||
|
uid == 0,
|
||||||
|
fmt.Sprintf("GetUserByAuthname(%s) -> userid %d", sup.Authname, uid))
|
||||||
|
|
||||||
|
uid2, _, _, _ := users.GetUserLogin("authname", sup.Authname, sup.Configid)
|
||||||
|
check("tenant web login does not find the supervisor",
|
||||||
|
uid2 == 0,
|
||||||
|
fmt.Sprintf("GetUserLogin(%s) -> userid %d", sup.Authname, uid2))
|
||||||
|
|
||||||
|
uid3, _ := users.FindUserID(sup.Authname, "", sup.Configid)
|
||||||
|
check("password-setup lookup does not find the supervisor",
|
||||||
|
uid3 == 0,
|
||||||
|
fmt.Sprintf("FindUserID(%s) -> userid %d", sup.Authname, uid3))
|
||||||
|
|
||||||
|
fmt.Println("\n4. till accounts are not listed as app users")
|
||||||
|
list, err := users.GetAllUsers(0, sup.Tenantid, 1, 500, "")
|
||||||
|
leaked := 0
|
||||||
|
for _, u := range list {
|
||||||
|
if u.Roleid == models.PosRoleSupervisor || u.Roleid == models.PosRoleCashier {
|
||||||
|
leaked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check("getallusers hides till accounts",
|
||||||
|
err == nil && leaked == 0,
|
||||||
|
fmt.Sprintf("%d of %d rows were till accounts", leaked, len(list)))
|
||||||
|
|
||||||
|
// ...but the POS console can still read its own people by asking for them.
|
||||||
|
sups, err := users.GetAllUsers(models.PosRoleSupervisor, sup.Tenantid, 1, 500, "")
|
||||||
|
check("asking for role 7 explicitly still works",
|
||||||
|
err == nil && len(sups) > 0,
|
||||||
|
fmt.Sprintf("%d supervisor(s) returned", len(sups)))
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
if failures > 0 {
|
||||||
|
fmt.Printf("%d CHECK(S) FAILED\n", failures)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("all checks passed")
|
||||||
|
}
|
||||||
171
scratch/posstaffsetup/main.go
Normal file
171
scratch/posstaffsetup/main.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// Creates a supervisor and a cashier at an outlet, then proves both can sign in.
|
||||||
|
//
|
||||||
|
// Exists because outlet 1135 — the one the terminal ships pointed at — had no
|
||||||
|
// staff at all, so the till fell back to the three PINs compiled into the app.
|
||||||
|
// Real staff here are what retire those.
|
||||||
|
//
|
||||||
|
// PINs are generated rather than chosen, from crypto/rand, and printed once so
|
||||||
|
// they can be handed to the shop. They are deliberately not derived from
|
||||||
|
// anything guessable.
|
||||||
|
//
|
||||||
|
// go run ./scratch/posstaffsetup plan 1087 1135
|
||||||
|
// go run ./scratch/posstaffsetup apply 1087 1135
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/repositories"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mode, tenantID, locationID := "plan", 1087, 1135
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
mode = os.Args[1]
|
||||||
|
}
|
||||||
|
if len(os.Args) > 3 {
|
||||||
|
tenantID, _ = strconv.Atoi(os.Args[2])
|
||||||
|
locationID, _ = strconv.Atoi(os.Args[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = 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)
|
||||||
|
}
|
||||||
|
repo := repositories.NewPosRepository(db)
|
||||||
|
|
||||||
|
var locName string
|
||||||
|
db.Raw(`SELECT COALESCE(locationname,'') FROM tenantlocations WHERE locationid=? AND tenantid=?`,
|
||||||
|
locationID, tenantID).Scan(&locName)
|
||||||
|
if locName == "" {
|
||||||
|
log.Fatalf("tenant %d has no outlet %d", tenantID, locationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The configid the shop's other accounts use, so a new cashier is visible
|
||||||
|
// to the same portal as everybody else at that outlet.
|
||||||
|
var configID int
|
||||||
|
db.Raw(`SELECT COALESCE(configid,0) FROM app_users
|
||||||
|
WHERE tenantid=? AND COALESCE(configid,0) > 0
|
||||||
|
GROUP BY configid ORDER BY COUNT(*) DESC LIMIT 1`, tenantID).Scan(&configID)
|
||||||
|
|
||||||
|
fmt.Printf("tenant %d, outlet %d (%s), configid %d\n\n", tenantID, locationID, locName, configID)
|
||||||
|
|
||||||
|
existing, err := repo.ListPosUsers(tenantID, locationID, true)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("till users already at this outlet: %d\n", len(existing))
|
||||||
|
for _, u := range existing {
|
||||||
|
fmt.Printf(" %-6d %-22s %-12s pin=%s %s\n", u.Userid, u.Fullname, u.Role, u.Pin, u.Status)
|
||||||
|
}
|
||||||
|
if len(existing) > 0 {
|
||||||
|
fmt.Println("\nAlready set up. Nothing to do — this refuses to add duplicates.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both roles get a username and a password as well as a PIN, and neither is
|
||||||
|
// stated here: CreatePosUser generates them and returns them once.
|
||||||
|
//
|
||||||
|
// A PIN cannot open a *closed* terminal — the PIN route requires a session
|
||||||
|
// that already exists — so a PIN-only account works only while somebody else
|
||||||
|
// is standing there to unlock the till first. For a supervisor that was an
|
||||||
|
// outright deadlock; for a cashier it means a shop that cannot open until
|
||||||
|
// two people have arrived. Whoever gets in at seven is as often the cashier
|
||||||
|
// as the supervisor.
|
||||||
|
wanted := []models.PosUserRequest{
|
||||||
|
{Fullname: "Store Supervisor", Role: "supervisor", Pin: newPin()},
|
||||||
|
{Fullname: "Counter Cashier", Role: "cashier", Pin: newPin()},
|
||||||
|
}
|
||||||
|
for wanted[0].Pin == wanted[1].Pin {
|
||||||
|
wanted[1].Pin = newPin()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nwould create:")
|
||||||
|
for _, w := range wanted {
|
||||||
|
fmt.Printf(" %-22s %-12s pin=%s (login generated on create)\n",
|
||||||
|
w.Fullname, w.Role, w.Pin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode != "apply" {
|
||||||
|
fmt.Println("\nNothing written — run `apply` to commit.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
for _, w := range wanted {
|
||||||
|
created, err := repo.CreatePosUser(tenantID, locationID, configID, w)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("creating %s: %v", w.Fullname, err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" created userid %-6d %-22s %-12s PIN %s\n",
|
||||||
|
created.Userid, created.Fullname, created.Role, created.Pin)
|
||||||
|
fmt.Printf(" login %s / %s\n", created.Authname, created.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The point of the exercise: does the till now see real staff?
|
||||||
|
staff, err := repo.PosStaff(tenantID, locationID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n/pos/staff now returns %d person(s):\n", len(staff))
|
||||||
|
for _, s := range staff {
|
||||||
|
fmt.Printf(" %-22s %-12s\n", s.Fullname, s.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And can they actually sign in?
|
||||||
|
fmt.Println("\nPIN sign-in:")
|
||||||
|
for _, w := range wanted {
|
||||||
|
session, err := repo.PosLoginByPin(tenantID, locationID, w.Pin)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" %-22s REFUSED: %v\n", w.Fullname, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-22s -> %s at %s, can_manage_staff=%v\n",
|
||||||
|
w.Fullname, session.Role, session.Locationname, session.Canmanagestaff)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.PosLoginByPin(tenantID, locationID, "5555"); err != nil {
|
||||||
|
fmt.Printf("\n an unknown PIN is refused: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("\n !! an unknown PIN was ACCEPTED")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n-- undo:")
|
||||||
|
fmt.Printf("UPDATE app_users SET status='InActive' WHERE tenantid=%d AND locationid=%d AND roleid IN (%d,%d);\n",
|
||||||
|
tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPin returns a four-digit PIN this schema can store, from crypto/rand.
|
||||||
|
//
|
||||||
|
// 1000–9999 because a leading zero cannot survive a bigint column, and the
|
||||||
|
// obvious ones are rejected by validatePosPin anyway — retried here rather than
|
||||||
|
// filtered, so the distribution stays even.
|
||||||
|
func newPin() string {
|
||||||
|
for {
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(9000))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
pin := strconv.FormatInt(n.Int64()+1000, 10)
|
||||||
|
switch pin {
|
||||||
|
case "1234", "1111", "2345", "3456", "4321", "9999", "2222":
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return pin
|
||||||
|
}
|
||||||
|
}
|
||||||
131
scratch/postilllogin/main.go
Normal file
131
scratch/postilllogin/main.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Gives every till account a way to open a closed terminal.
|
||||||
|
//
|
||||||
|
// A PIN cannot do it: the PIN route requires a session that already exists, so
|
||||||
|
// a PIN-only account works only while somebody else is standing there to unlock
|
||||||
|
// the till first. For a supervisor that was an outright deadlock. For a cashier
|
||||||
|
// it means a shop that cannot open until two people have arrived — and whoever
|
||||||
|
// gets in at seven is as often the cashier as the supervisor.
|
||||||
|
//
|
||||||
|
// So both roles get a username and a password. This backfills the ones created
|
||||||
|
// before that was understood; new accounts get them from CreatePosUser.
|
||||||
|
//
|
||||||
|
// go run ./scratch/postilllogin plan
|
||||||
|
// go run ./scratch/postilllogin apply
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/repositories"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newPassword() string {
|
||||||
|
// No l/I/O/0/1 — these get read off a screen and typed at a counter.
|
||||||
|
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
out := make([]byte, 14)
|
||||||
|
for i := range out {
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("generating a password: %v", err)
|
||||||
|
}
|
||||||
|
out[i] = alphabet[n.Int64()]
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mode := "plan"
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
mode = os.Args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = 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)
|
||||||
|
}
|
||||||
|
repo := repositories.NewPosRepository(db)
|
||||||
|
|
||||||
|
type target struct {
|
||||||
|
Userid, Tenantid, Locationid, Roleid int
|
||||||
|
Fullname, Locationname string
|
||||||
|
}
|
||||||
|
var targets []target
|
||||||
|
db.Raw(`SELECT a.userid, a.tenantid, a.locationid, COALESCE(a.roleid,0) AS roleid,
|
||||||
|
TRIM(COALESCE(a.firstname,'')||' '||COALESCE(a.lastname,'')) AS fullname,
|
||||||
|
COALESCE(l.locationname,'') AS locationname
|
||||||
|
FROM app_users a
|
||||||
|
LEFT JOIN tenantlocations l
|
||||||
|
ON l.locationid = a.locationid AND l.tenantid = a.tenantid
|
||||||
|
WHERE COALESCE(a.roleid,0) IN (?, ?)
|
||||||
|
AND (COALESCE(a.password,'') = '' OR COALESCE(a.authname,'') = '')
|
||||||
|
AND LOWER(COALESCE(a.status,'active')) <> 'inactive'
|
||||||
|
ORDER BY a.userid`, models.PosRoleSupervisor, models.PosRoleCashier).Scan(&targets)
|
||||||
|
|
||||||
|
if len(targets) == 0 {
|
||||||
|
fmt.Println("Every till account already has a login. Nothing to do.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("till accounts with no way to open a closed terminal: %d\n\n", len(targets))
|
||||||
|
|
||||||
|
for _, t := range targets {
|
||||||
|
authname := fmt.Sprintf("%s.%d@pos.nearle.in",
|
||||||
|
strings.ToLower(models.PosRoleName(t.Roleid)), t.Locationid)
|
||||||
|
password := newPassword()
|
||||||
|
|
||||||
|
if mode != "apply" {
|
||||||
|
fmt.Printf(" %-6d %-18s outlet %-6d %-26s -> %s / %s\n",
|
||||||
|
t.Userid, t.Fullname, t.Locationid, t.Locationname, authname, password)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := repo.UpdatePosUser(t.Tenantid, t.Locationid, models.PosUserRequest{
|
||||||
|
Userid: t.Userid,
|
||||||
|
Authname: authname,
|
||||||
|
Password: password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" %-6d FAILED: %v\n", t.Userid, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prove it, rather than assert it — the whole point of this tool is that
|
||||||
|
// a supervisor who cannot sign in is indistinguishable from one who can
|
||||||
|
// until somebody stands at a counter and tries.
|
||||||
|
session, err := repo.PosLogin(models.PosLoginRequest{
|
||||||
|
Authname: authname,
|
||||||
|
Password: password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" %-6d written, but sign-in still fails: %v\n", t.Userid, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" %-6d %-18s outlet %-6d %-26s\n", t.Userid, t.Fullname, t.Locationid, t.Locationname)
|
||||||
|
fmt.Printf(" login %s / %s\n", authname, password)
|
||||||
|
fmt.Printf(" opens as %s at %s, can_manage_staff=%v\n",
|
||||||
|
session.Role, session.Locationname, session.Canmanagestaff)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode != "apply" {
|
||||||
|
fmt.Println("\nNothing written — run `apply` to commit.")
|
||||||
|
}
|
||||||
|
}
|
||||||
194
scratch/seedprices/main.go
Normal file
194
scratch/seedprices/main.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// Seed retail prices so the POS has something sellable.
|
||||||
|
//
|
||||||
|
// These are plausible Coimbatore figures, not authoritative ones. They exist so
|
||||||
|
// the terminal can ring a real bill; the owner corrects them afterwards.
|
||||||
|
//
|
||||||
|
// go run ./scratch/seedprices plan # show every change and the undo SQL
|
||||||
|
// go run ./scratch/seedprices apply # write them
|
||||||
|
// go run ./scratch/seedprices verify # read back what the catalogue now serves
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type priced struct {
|
||||||
|
locationID int
|
||||||
|
productID int
|
||||||
|
name string
|
||||||
|
unit string
|
||||||
|
price float64
|
||||||
|
note string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prices are per the product's own unit — per kilogram where the unit is
|
||||||
|
// kilogram, per pack where it is piece. Getting that backwards is the easiest
|
||||||
|
// way to make a till look broken, so the unit is carried through and printed.
|
||||||
|
var seed = []priced{
|
||||||
|
// 1135 — fresh produce
|
||||||
|
{1135, 6988, "Mysore Banana", "kilogram", 60, ""},
|
||||||
|
{1135, 6989, "Jammu Apple", "piece", 30, "per fruit, not per kg"},
|
||||||
|
{1135, 6990, "Small orange", "kilogram", 90, ""},
|
||||||
|
{1135, 6991, "Red Guava", "kilogram", 80, ""},
|
||||||
|
{1135, 6992, "Pomegrante", "kilogram", 180, ""},
|
||||||
|
{1135, 6993, "Salem Mango", "kilogram", 90, "seasonal, swings 80-120"},
|
||||||
|
{1135, 6994, "Pineapple", "kilogram", 60, ""},
|
||||||
|
{1135, 6995, "Strawberries", "piece", 150, "priced as a punnet"},
|
||||||
|
{1135, 6996, "Maceral", "kilogram", 220, "READ AS MACKEREL - correct if wrong"},
|
||||||
|
{1135, 6997, "Tuna", "kilogram", 280, ""},
|
||||||
|
{1135, 6998, "Hatsun curd", "piece", 30, "500g pouch"},
|
||||||
|
{1135, 7014, "Apple", "kilogram", 200, ""},
|
||||||
|
|
||||||
|
// 1185 — packaged
|
||||||
|
{1185, 7074, "Amla Dabur Oral Care Chewing Gum 10g", "piece", 10, ""},
|
||||||
|
{1185, 7075, "Cheetos Chips 100g", "piece", 40, ""},
|
||||||
|
{1185, 7076, "Cheerios Breakfast Cereal 100g", "piece", 120, ""},
|
||||||
|
// 7077 Hot Heads is already at 50 — someone set it deliberately, leave it.
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mode := "plan"
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
mode = os.Args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case "plan":
|
||||||
|
plan(db, false)
|
||||||
|
case "apply":
|
||||||
|
plan(db, true)
|
||||||
|
case "verify":
|
||||||
|
verify(db)
|
||||||
|
default:
|
||||||
|
log.Fatalf("unknown mode %q — use plan, apply or verify", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func plan(db *gorm.DB, write bool) {
|
||||||
|
fmt.Printf("%-6s %-38s %-9s %8s -> %8s\n", "id", "product", "unit", "now", "new")
|
||||||
|
fmt.Println("--------------------------------------------------------------------------------")
|
||||||
|
|
||||||
|
undo := []string{}
|
||||||
|
changes := 0
|
||||||
|
|
||||||
|
for _, p := range seed {
|
||||||
|
var current struct {
|
||||||
|
Price float64
|
||||||
|
Tenantid int
|
||||||
|
Found bool
|
||||||
|
}
|
||||||
|
row := db.Raw(`SELECT COALESCE(price, 0) AS price, tenantid, true AS found
|
||||||
|
FROM productlocations
|
||||||
|
WHERE productid = ? AND locationid = ?
|
||||||
|
LIMIT 1`, p.productID, p.locationID).Scan(¤t)
|
||||||
|
if row.Error != nil {
|
||||||
|
log.Fatalf("reading %d: %v", p.productID, row.Error)
|
||||||
|
}
|
||||||
|
if !current.Found {
|
||||||
|
fmt.Printf("%-6d %-38s NO productlocations ROW - skipped\n", p.productID, p.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never overwrite a price a human already set. A seed value is a
|
||||||
|
// placeholder; a real one is a decision, and losing it silently would
|
||||||
|
// be worse than leaving a gap.
|
||||||
|
if current.Price > 0 {
|
||||||
|
fmt.Printf("%-6d %-38s %-9s %8.2f already priced, left alone\n",
|
||||||
|
p.productID, p.name, p.unit, current.Price)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
note := ""
|
||||||
|
if p.note != "" {
|
||||||
|
note = " <- " + p.note
|
||||||
|
}
|
||||||
|
fmt.Printf("%-6d %-38s %-9s %8.2f -> %8.2f%s\n",
|
||||||
|
p.productID, p.name, p.unit, current.Price, p.price, note)
|
||||||
|
|
||||||
|
undo = append(undo, fmt.Sprintf(
|
||||||
|
"UPDATE productlocations SET price = %.2f WHERE productid = %d AND locationid = %d;",
|
||||||
|
current.Price, p.productID, p.locationID))
|
||||||
|
changes++
|
||||||
|
|
||||||
|
if write {
|
||||||
|
// updated is bumped so the catalogue delta carries the new price to
|
||||||
|
// terminals that already hold a revision, rather than waiting for
|
||||||
|
// someone to force a full pull.
|
||||||
|
err := db.Exec(`UPDATE productlocations
|
||||||
|
SET price = ?, updated = NOW()
|
||||||
|
WHERE productid = ? AND locationid = ?`,
|
||||||
|
p.price, p.productID, p.locationID).Error
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("writing %d: %v", p.productID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("--------------------------------------------------------------------------------")
|
||||||
|
if write {
|
||||||
|
fmt.Printf("APPLIED %d price(s).\n\n", changes)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%d price(s) would change. Nothing written — run `apply` to commit.\n\n", changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("-- undo, if you want the zeros back:")
|
||||||
|
for _, u := range undo {
|
||||||
|
fmt.Println(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func verify(db *gorm.DB) {
|
||||||
|
type row struct {
|
||||||
|
Locationid int
|
||||||
|
Productid int
|
||||||
|
Productname string
|
||||||
|
Price float64
|
||||||
|
Taxpercent float64
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
db.Raw(`SELECT b.locationid, a.productid, a.productname,
|
||||||
|
COALESCE(b.price, 0) AS price, COALESCE(a.taxpercent, 0) AS taxpercent
|
||||||
|
FROM products a
|
||||||
|
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
|
||||||
|
WHERE b.locationid IN (1135, 1185) AND a.productid > 0
|
||||||
|
ORDER BY b.locationid, a.productid`).Scan(&rows)
|
||||||
|
|
||||||
|
sellable := 0
|
||||||
|
for _, r := range rows {
|
||||||
|
flag := ""
|
||||||
|
if r.Price > 0 {
|
||||||
|
sellable++
|
||||||
|
} else {
|
||||||
|
flag = " <- still zero, not sellable"
|
||||||
|
}
|
||||||
|
fmt.Printf("loc %d %-6d %-38s %8.2f tax=%.0f%s\n",
|
||||||
|
r.Locationid, r.Productid, r.Productname[:min(38, len(r.Productname))], r.Price, r.Taxpercent, flag)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n%d of %d rows are sellable.\n", sellable, len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
90
scratch/termfixcleanup/main.go
Normal file
90
scratch/termfixcleanup/main.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Removes the single bill posted to prove the terminalid fix on v1.3.96.
|
||||||
|
//
|
||||||
|
// Named by its own terminalorderid rather than by date or by "the newest row" —
|
||||||
|
// pos_orders holds real takings, and is not a table to run an unbounded DELETE
|
||||||
|
// against. Stock is returned before the bill is deleted, so the ledger is never
|
||||||
|
// left short with nothing remaining to explain why.
|
||||||
|
//
|
||||||
|
// go run ./scratch/termfixcleanup
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tenantID = 1087
|
||||||
|
locationID = 1135
|
||||||
|
testOrder = "a1b2c3d4-0000-4000-8000-termfix00001"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 billIDs []int
|
||||||
|
db.Raw(`SELECT posorderid FROM pos_orders WHERE terminalorderid = ?`,
|
||||||
|
testOrder).Scan(&billIDs)
|
||||||
|
|
||||||
|
if len(billIDs) == 0 {
|
||||||
|
fmt.Println("no test bill found — nothing to undo")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("found test bill(s): %v\n", billIDs)
|
||||||
|
|
||||||
|
var consumed []struct {
|
||||||
|
Productid int
|
||||||
|
Quantity float64
|
||||||
|
}
|
||||||
|
db.Raw(`SELECT productid, SUM(quantity) AS quantity
|
||||||
|
FROM pos_order_items WHERE posorderid IN ?
|
||||||
|
GROUP BY productid`, billIDs).Scan(&consumed)
|
||||||
|
|
||||||
|
for _, c := range consumed {
|
||||||
|
qty := int(c.Quantity)
|
||||||
|
if float64(qty) < c.Quantity {
|
||||||
|
qty++ // the ingest rounds up, so the reversal must too
|
||||||
|
}
|
||||||
|
if err := db.Exec(`
|
||||||
|
INSERT INTO productstocks (tenantid, stockdate, locationid, productid,
|
||||||
|
quantity, stocktype, status)
|
||||||
|
VALUES (?, NOW(), ?, ?, ?, 'in', 'Active')`,
|
||||||
|
tenantID, locationID, c.Productid, qty).Error; err != nil {
|
||||||
|
fmt.Println(" return stock:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" returned %d unit(s) of product %d\n", qty, c.Productid)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Exec(`DELETE FROM pos_order_items WHERE posorderid IN ?`, billIDs)
|
||||||
|
db.Exec(`DELETE FROM pos_orders WHERE posorderid IN ?`, billIDs)
|
||||||
|
fmt.Printf(" deleted %d bill(s) and their items\n", len(billIDs))
|
||||||
|
|
||||||
|
var left int64
|
||||||
|
db.Raw(`SELECT COUNT(*) FROM pos_orders WHERE terminalorderid = ?`, testOrder).Scan(&left)
|
||||||
|
fmt.Printf("\nremaining test rows: %d\n", left)
|
||||||
|
|
||||||
|
var stock float64
|
||||||
|
db.Raw(`SELECT COALESCE(SUM(CASE WHEN LOWER(stocktype)='in' THEN quantity ELSE 0 END) -
|
||||||
|
SUM(CASE WHEN LOWER(stocktype)='out' THEN quantity ELSE 0 END), 0)
|
||||||
|
FROM productstocks WHERE productid = 6988 AND locationid = ? AND tenantid = ?`,
|
||||||
|
locationID, tenantID).Scan(&stock)
|
||||||
|
fmt.Printf("Mysore Banana stock now: %.0f (was 750 before any probe)\n", stock)
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
|
"nearle/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PosService interface {
|
type PosService interface {
|
||||||
@@ -23,6 +25,33 @@ type PosService interface {
|
|||||||
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
|
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
|
||||||
SaleDetail(locationID int, reference string) (*models.PosOrders, error)
|
SaleDetail(locationID int, reference string) (*models.PosOrders, error)
|
||||||
SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error)
|
SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error)
|
||||||
|
|
||||||
|
// Login authenticates a person against the same account store the web
|
||||||
|
// console uses and mints the session a till carries for the trading day.
|
||||||
|
Login(req models.PosLoginRequest) (*models.PosSession, error)
|
||||||
|
|
||||||
|
// LocationAllowed is the authorisation check every other POS call rests on:
|
||||||
|
// does the tenant in the caller's token actually own this outlet.
|
||||||
|
LocationAllowed(tenantID, locationID int) (bool, error)
|
||||||
|
|
||||||
|
// Staff lists who may ring a bill at an outlet. Sent with the session and
|
||||||
|
// available on its own, so a shop that hires someone mid-shift can pull them
|
||||||
|
// down without signing the terminal out.
|
||||||
|
Staff(tenantID, locationID int) ([]models.PosStaffMember, error)
|
||||||
|
|
||||||
|
// Till staff management, all scoped to the caller's own outlet.
|
||||||
|
CreateUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error)
|
||||||
|
UpdateUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error)
|
||||||
|
ListUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error)
|
||||||
|
DeactivateUser(tenantID, locationID, userID int) error
|
||||||
|
|
||||||
|
// LoginWithPin signs a person in at a terminal that is already open. Never
|
||||||
|
// reachable anonymously — four digits is not a barrier on its own.
|
||||||
|
LoginWithPin(tenantID, locationID int, pin string) (*models.PosSession, error)
|
||||||
|
|
||||||
|
// ConfigidFor infers which portal a tenant's people belong to, so the console
|
||||||
|
// is never asked for a number nobody can look up.
|
||||||
|
ConfigidFor(tenantID int) int
|
||||||
}
|
}
|
||||||
|
|
||||||
type posService struct {
|
type posService struct {
|
||||||
@@ -69,3 +98,81 @@ func (s *posService) SaleDetail(locationID int, reference string) (*models.PosOr
|
|||||||
func (s *posService) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) {
|
func (s *posService) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) {
|
||||||
return s.repo.SalesSummary(f)
|
return s.repo.SalesSummary(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Login authenticates a terminal's operator and issues its session.
|
||||||
|
//
|
||||||
|
// The token is minted here rather than in the repository so that the signing
|
||||||
|
// key stays out of the layer that talks to the database, and so a future change
|
||||||
|
// of token format touches one function.
|
||||||
|
func (s *posService) Login(req models.PosLoginRequest) (*models.PosSession, error) {
|
||||||
|
session, err := s.repo.PosLogin(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.mint(session, req.Terminalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mint signs a resolved session.
|
||||||
|
//
|
||||||
|
// Kept apart from the credential checks so the signing key stays out of the
|
||||||
|
// layer that talks to the database, and so a change of token format touches one
|
||||||
|
// function rather than every way in.
|
||||||
|
func (s *posService) mint(session *models.PosSession, terminalID string) (*models.PosSession, error) {
|
||||||
|
token, expires, err := utils.MintPosToken(utils.PosClaims{
|
||||||
|
Userid: session.Userid,
|
||||||
|
Tenantid: session.Tenantid,
|
||||||
|
Locationid: session.Locationid,
|
||||||
|
Roleid: session.Roleid,
|
||||||
|
Configid: session.Configid,
|
||||||
|
Terminalid: terminalID,
|
||||||
|
}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Token = token
|
||||||
|
session.Expiresat = expires.UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *posService) LocationAllowed(tenantID, locationID int) (bool, error) {
|
||||||
|
return s.repo.PosLocationAllowed(tenantID, locationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *posService) Staff(tenantID, locationID int) ([]models.PosStaffMember, error) {
|
||||||
|
return s.repo.PosStaff(tenantID, locationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *posService) CreateUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error) {
|
||||||
|
return s.repo.CreatePosUser(tenantID, locationID, configID, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *posService) UpdateUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error) {
|
||||||
|
return s.repo.UpdatePosUser(tenantID, locationID, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *posService) ListUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error) {
|
||||||
|
return s.repo.ListPosUsers(tenantID, locationID, includeInactive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *posService) DeactivateUser(tenantID, locationID, userID int) error {
|
||||||
|
return s.repo.DeactivatePosUser(tenantID, locationID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginWithPin mints a fresh session for whoever the PIN belongs to.
|
||||||
|
//
|
||||||
|
// A new token rather than a reused one, because the token carries the role and
|
||||||
|
// a cashier taking over from a supervisor must not inherit their permissions.
|
||||||
|
func (s *posService) LoginWithPin(tenantID, locationID int, pin string) (*models.PosSession, error) {
|
||||||
|
session, err := s.repo.PosLoginByPin(tenantID, locationID, pin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.mint(session, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *posService) ConfigidFor(tenantID int) int {
|
||||||
|
return s.repo.PosConfigidFor(tenantID)
|
||||||
|
}
|
||||||
|
|||||||
164
utils/postoken.go
Normal file
164
utils/postoken.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session tokens for the POS terminal.
|
||||||
|
//
|
||||||
|
// A till is not a browser. It signs in once when a shop opens and then bills
|
||||||
|
// for a whole trading day — often on a connection that comes and goes — so the
|
||||||
|
// thing it carries has to survive a reboot, a lost network, and an hour in a
|
||||||
|
// drawer. That rules out a server-side session table (a till that cannot reach
|
||||||
|
// us must still be able to prove who it is when it comes back) and it rules out
|
||||||
|
// a short expiry.
|
||||||
|
//
|
||||||
|
// So: a signed, self-describing token. Everything needed to authorise a request
|
||||||
|
// is inside it, and the signature is what makes it trustworthy. No database
|
||||||
|
// round trip on the hot path, and nothing to replicate between pods.
|
||||||
|
//
|
||||||
|
// Deliberately not JWT. The backend has no JWT dependency today, and the format
|
||||||
|
// buys nothing here — there is exactly one issuer, one audience and one
|
||||||
|
// algorithm, so the header that JWT spends bytes negotiating is a constant. The
|
||||||
|
// `alg` field is also the source of JWT's worst-known footgun (`alg: none`),
|
||||||
|
// and a format with no algorithm field cannot have that bug.
|
||||||
|
//
|
||||||
|
// Wire format is `base64url(payload).base64url(hmac-sha256)`, and the MAC is
|
||||||
|
// taken over the encoded payload rather than the raw JSON so that verification
|
||||||
|
// never has to re-serialise anything to check it.
|
||||||
|
|
||||||
|
// PosClaims is what a terminal proves about itself on every request.
|
||||||
|
//
|
||||||
|
// Locationid is the load-bearing field. Before this existed the till named its
|
||||||
|
// own store on the wire and was believed, so changing one number in Settings
|
||||||
|
// moved a terminal into another tenant's books. Now the location is decided at
|
||||||
|
// sign-in, from the user's own record, and sealed under the signature.
|
||||||
|
type PosClaims struct {
|
||||||
|
Userid int `json:"uid"`
|
||||||
|
Tenantid int `json:"tid"`
|
||||||
|
Locationid int `json:"lid"`
|
||||||
|
Roleid int `json:"rid"`
|
||||||
|
Configid int `json:"cid"`
|
||||||
|
Terminalid string `json:"trm,omitempty"`
|
||||||
|
Issuedat int64 `json:"iat"`
|
||||||
|
Expiresat int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosTokenTTL is how long a till stays signed in.
|
||||||
|
//
|
||||||
|
// Thirty days rather than hours. A shop signs the terminal in once and expects
|
||||||
|
// it to keep working; forcing a re-login mid-shift would mean a queue of
|
||||||
|
// customers waiting while somebody finds the manager's password. The exposure
|
||||||
|
// that buys is bounded by the token naming a single location — a leaked one
|
||||||
|
// bills into the shop it was already for.
|
||||||
|
const PosTokenTTL = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// posTokenSecret is the signing key.
|
||||||
|
//
|
||||||
|
// Fails loudly rather than falling back to a baked-in default. A hardcoded
|
||||||
|
// development secret has a way of reaching production, and a signing key that
|
||||||
|
// everyone with the source can compute is the same as no signature at all —
|
||||||
|
// anyone could mint a token for any tenant.
|
||||||
|
func posTokenSecret() ([]byte, error) {
|
||||||
|
secret := strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET"))
|
||||||
|
if secret == "" {
|
||||||
|
// Falls back to the key the config file already carries, so a
|
||||||
|
// deployment that set that one does not need a second variable.
|
||||||
|
secret = strings.TrimSpace(os.Getenv("JWT_SECRET_KEY"))
|
||||||
|
}
|
||||||
|
if secret == "" {
|
||||||
|
return nil, fmt.Errorf("POS_TOKEN_SECRET is not set; terminals cannot be issued sessions")
|
||||||
|
}
|
||||||
|
if len(secret) < 16 {
|
||||||
|
return nil, fmt.Errorf("POS_TOKEN_SECRET is too short to sign with; use at least 16 characters")
|
||||||
|
}
|
||||||
|
return []byte(secret), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MintPosToken issues a session for a signed-in terminal.
|
||||||
|
func MintPosToken(claims PosClaims, now time.Time) (string, time.Time, error) {
|
||||||
|
secret, err := posTokenSecret()
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
expires := now.Add(PosTokenTTL)
|
||||||
|
claims.Issuedat = now.Unix()
|
||||||
|
claims.Expiresat = expires.Unix()
|
||||||
|
|
||||||
|
payload, err := json.Marshal(claims)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
||||||
|
return encoded + "." + sign(encoded, secret), expires, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePosToken verifies a token and returns what it claims.
|
||||||
|
//
|
||||||
|
// Order matters: the signature is checked before the payload is trusted for
|
||||||
|
// anything, including expiry. Reading `exp` out of an unverified payload and
|
||||||
|
// acting on it would be taking the attacker's word for when their own token
|
||||||
|
// runs out.
|
||||||
|
func ParsePosToken(token string, now time.Time) (PosClaims, error) {
|
||||||
|
secret, err := posTokenSecret()
|
||||||
|
if err != nil {
|
||||||
|
return PosClaims{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, signature, found := strings.Cut(strings.TrimSpace(token), ".")
|
||||||
|
if !found || encoded == "" || signature == "" {
|
||||||
|
return PosClaims{}, fmt.Errorf("malformed session token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant time, so a caller cannot learn the right signature one byte at a
|
||||||
|
// time from how long the comparison took.
|
||||||
|
if !hmac.Equal([]byte(signature), []byte(sign(encoded, secret))) {
|
||||||
|
return PosClaims{}, fmt.Errorf("session token signature does not verify")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return PosClaims{}, fmt.Errorf("malformed session token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims PosClaims
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return PosClaims{}, fmt.Errorf("malformed session token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Expiresat > 0 && now.Unix() >= claims.Expiresat {
|
||||||
|
return PosClaims{}, fmt.Errorf("session has expired; sign in again")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token that verifies but names no outlet would authorise nothing and
|
||||||
|
// must not be mistaken for one that authorises everything.
|
||||||
|
if claims.Locationid <= 0 || claims.Tenantid <= 0 {
|
||||||
|
return PosClaims{}, fmt.Errorf("session token names no outlet")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sign(encoded string, secret []byte) string {
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
mac.Write([]byte(encoded))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosTokenConfigured reports whether sessions can be issued at all.
|
||||||
|
//
|
||||||
|
// Lets the server say "this deployment has no signing key" once at start-up
|
||||||
|
// rather than answering every sign-in with a 500.
|
||||||
|
func PosTokenConfigured() bool {
|
||||||
|
_, err := posTokenSecret()
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
166
utils/postoken_test.go
Normal file
166
utils/postoken_test.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func withSecret(t *testing.T, secret string) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
const testSecret = "a-test-signing-key-long-enough"
|
||||||
|
|
||||||
|
func TestASessionSurvivesTheRoundTrip(t *testing.T) {
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
token, expires, err := MintPosToken(PosClaims{
|
||||||
|
Userid: 42, Tenantid: 1087, Locationid: 1135, Roleid: 3, Terminalid: "T5EDD",
|
||||||
|
}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := ParsePosToken(token, now.Add(time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsing a token we just issued: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Tenantid != 1087 || claims.Locationid != 1135 {
|
||||||
|
t.Fatalf("the outlet did not survive: tenant %d location %d", claims.Tenantid, claims.Locationid)
|
||||||
|
}
|
||||||
|
if claims.Terminalid != "T5EDD" {
|
||||||
|
t.Fatalf("terminal id lost: %q", claims.Terminalid)
|
||||||
|
}
|
||||||
|
if !expires.After(now) {
|
||||||
|
t.Fatalf("expiry %v is not after issue %v", expires, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of signing. Before this existed a till named its own outlet
|
||||||
|
// on the wire and was believed, so this is the test that says it no longer can.
|
||||||
|
func TestARewrittenOutletIsRefused(t *testing.T) {
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 1087, Locationid: 1135}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tamper: decode the payload, move it to another tenant's outlet, re-encode
|
||||||
|
// and keep the original signature — exactly what an attacker holding a real
|
||||||
|
// token would try.
|
||||||
|
encoded, signature, _ := strings.Cut(token, ".")
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding our own payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims PosClaims
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
t.Fatalf("unmarshalling our own payload: %v", err)
|
||||||
|
}
|
||||||
|
claims.Tenantid = 916
|
||||||
|
claims.Locationid = 1185
|
||||||
|
|
||||||
|
forged, _ := json.Marshal(claims)
|
||||||
|
tampered := base64.RawURLEncoding.EncodeToString(forged) + "." + signature
|
||||||
|
|
||||||
|
if _, err := ParsePosToken(tampered, now); err == nil {
|
||||||
|
t.Fatal("a token whose outlet was rewritten was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnExpiredSessionIsRefused(t *testing.T) {
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 1087, Locationid: 1135}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ParsePosToken(token, now.Add(PosTokenTTL+time.Minute)); err == nil {
|
||||||
|
t.Fatal("an expired session was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token signed by somebody else must not verify here, or the signature is
|
||||||
|
// decoration.
|
||||||
|
func TestATokenFromAnotherKeyIsRefused(t *testing.T) {
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 1087, Locationid: 1135}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
withSecret(t, "a-completely-different-key-here")
|
||||||
|
if _, err := ParsePosToken(token, now); err == nil {
|
||||||
|
t.Fatal("a token signed with another key verified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMalformedTokenIsRefused(t *testing.T) {
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for _, token := range []string{
|
||||||
|
"",
|
||||||
|
"nodot",
|
||||||
|
".",
|
||||||
|
"only.",
|
||||||
|
".onlysignature",
|
||||||
|
"not-base64!.also-not-base64!",
|
||||||
|
} {
|
||||||
|
if _, err := ParsePosToken(token, now); err == nil {
|
||||||
|
t.Fatalf("malformed token %q was accepted", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deployment with no signing key must fail loudly rather than fall back to a
|
||||||
|
// key anyone reading the source could compute.
|
||||||
|
func TestNoSecretMeansNoSessions(t *testing.T) {
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", "")
|
||||||
|
t.Setenv("JWT_SECRET_KEY", "")
|
||||||
|
|
||||||
|
if PosTokenConfigured() {
|
||||||
|
t.Fatal("reported configured with no secret set")
|
||||||
|
}
|
||||||
|
if _, _, err := MintPosToken(PosClaims{Tenantid: 1, Locationid: 1}, time.Now()); err == nil {
|
||||||
|
t.Fatal("minted a session with no signing key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAShortSecretIsRefused(t *testing.T) {
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", "short")
|
||||||
|
t.Setenv("JWT_SECRET_KEY", "")
|
||||||
|
|
||||||
|
if _, _, err := MintPosToken(PosClaims{Tenantid: 1, Locationid: 1}, time.Now()); err == nil {
|
||||||
|
t.Fatal("signed with a secret too short to be worth signing with")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token that verifies but names no outlet authorises nothing, and must not be
|
||||||
|
// mistaken for one that authorises everything.
|
||||||
|
func TestASessionNamingNoOutletIsRefused(t *testing.T) {
|
||||||
|
withSecret(t, testSecret)
|
||||||
|
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 0, Locationid: 0}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ParsePosToken(token, now); err == nil {
|
||||||
|
t.Fatal("a session naming no outlet was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user