Compare commits
24 Commits
27fbbf0422
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7895d3ccf | ||
|
|
9e9401215d | ||
|
|
c0a7fbc1b1 | ||
|
|
f5e16b54cc | ||
|
|
cd2459dbb6 | ||
|
|
6a62dbb9f3 | ||
|
|
f343f4e86e | ||
|
|
4b27b84b1f | ||
|
|
c696ec3e79 | ||
|
|
c4dfcd5387 | ||
|
|
12165d5e58 | ||
|
|
5864204d32 | ||
|
|
d0c3cb751e | ||
|
|
11595ad415 | ||
|
|
ec672a3087 | ||
| bddd8fa265 | |||
|
|
8aa4d86eb6 | ||
|
|
b0caacd90a | ||
|
|
b9f389fcdf | ||
|
|
1e3386fac8 | ||
|
|
fc81df14e4 | ||
|
|
8709556704 | ||
|
|
ef647d3395 | ||
|
|
64a219e7da |
21
.env
21
.env
@@ -1,21 +0,0 @@
|
|||||||
APP_PORT=1009
|
|
||||||
DB_HOST=66.116.207.225
|
|
||||||
DB_PORT=5433
|
|
||||||
DB_NAME=nearledb
|
|
||||||
DB_USER=admin
|
|
||||||
DB_PASSWORD="Package@123#"
|
|
||||||
|
|
||||||
# --- Catalogue Postgres / pgvector (separate DB, read-only integration) ---
|
|
||||||
CATALOGUE_DB_HOST=31.97.228.132
|
|
||||||
CATALOGUE_DB_PORT=6054
|
|
||||||
CATALOGUE_DB_NAME=pgvector
|
|
||||||
CATALOGUE_DB_USER=admin
|
|
||||||
CATALOGUE_DB_PASSWORD="'Package@321#'"
|
|
||||||
|
|
||||||
# --- DigitalOcean Spaces (S3-compatible), catalogue product images ---
|
|
||||||
USE_S3=true
|
|
||||||
S3_ACCESS_KEY=DO801G8Q8JAZKF49U3WJ
|
|
||||||
S3_SECRET_KEY=lBQExYfkVqH+ybmGVmQH5MkThBbrIohA/VQLgcPUvug
|
|
||||||
S3_ENDPOINT=https://nearle.sgp1.digitaloceanspaces.com
|
|
||||||
S3_BUCKET=nearle
|
|
||||||
S3_REGION=sgp1
|
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -48,3 +48,11 @@ Thumbs.db
|
|||||||
*.mov
|
*.mov
|
||||||
*.wmv
|
*.wmv
|
||||||
|
|
||||||
|
|
||||||
|
# Local configuration. Tracked until 2026-08-03, which put the database
|
||||||
|
# credentials in this repository's history — removing it from the index stops
|
||||||
|
# that getting worse, but the existing history still has them and the password
|
||||||
|
# should be rotated.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
@@ -94,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"))
|
||||||
@@ -188,6 +251,107 @@ func (ctl *PosController) LocationHealth(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Sales reads
|
||||||
|
//
|
||||||
|
// Unlike the ingest handlers above, these answer in the usual
|
||||||
|
// `{code, message, status, details}` envelope — they are read by the web app,
|
||||||
|
// not by a terminal, and nothing about them is bound to the till's contract.
|
||||||
|
|
||||||
|
// posSalesFilter reads the shared query parameters.
|
||||||
|
func posSalesFilter(c *fiber.Ctx) (models.PosSalesFilter, error) {
|
||||||
|
locationID, err := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
||||||
|
if err != nil || locationID <= 0 {
|
||||||
|
return models.PosSalesFilter{}, fmt.Errorf("locationid is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
pageno, _ := strconv.Atoi(c.Query("pageno", "0"))
|
||||||
|
pagesize, _ := strconv.Atoi(c.Query("pagesize", "50"))
|
||||||
|
|
||||||
|
return models.PosSalesFilter{
|
||||||
|
Locationid: locationID,
|
||||||
|
Fromdate: strings.TrimSpace(c.Query("fromdate")),
|
||||||
|
Todate: strings.TrimSpace(c.Query("todate")),
|
||||||
|
Terminalid: strings.TrimSpace(c.Query("terminalid")),
|
||||||
|
Cashiername: strings.TrimSpace(c.Query("cashiername")),
|
||||||
|
Paymentmode: strings.TrimSpace(c.Query("paymentmode")),
|
||||||
|
Pageno: pageno,
|
||||||
|
Pagesize: pagesize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSales lists counter bills for an outlet, newest first.
|
||||||
|
func (ctl *PosController) GetSales(c *fiber.Ctx) error {
|
||||||
|
filter, err := posSalesFilter(c)
|
||||||
|
if err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := ctl.posService.Sales(filter)
|
||||||
|
if err != nil {
|
||||||
|
return posServerError(c, "GetSales", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": page})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSaleDetail returns one bill with its lines.
|
||||||
|
//
|
||||||
|
// Accepts the terminal's order UUID, the invoice number, or this backend's
|
||||||
|
// posorderid — a support call starts from whichever the caller is looking at.
|
||||||
|
func (ctl *PosController) GetSaleDetail(c *fiber.Ctx) error {
|
||||||
|
locationID, err := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
||||||
|
if err != nil || locationID <= 0 {
|
||||||
|
return posBadRequest(c, fmt.Errorf("locationid is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
reference := strings.TrimSpace(c.Query("reference"))
|
||||||
|
if reference == "" {
|
||||||
|
return posBadRequest(c, fmt.Errorf("reference is required — an order id, invoice number or posorderid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
bill, err := ctl.posService.SaleDetail(locationID, reference)
|
||||||
|
if err != nil {
|
||||||
|
return posServerError(c, "GetSaleDetail", err)
|
||||||
|
}
|
||||||
|
if bill == nil {
|
||||||
|
return c.Status(http.StatusNotFound).JSON(fiber.Map{
|
||||||
|
"code": http.StatusNotFound,
|
||||||
|
"message": "no bill matches that reference at this outlet",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": bill})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSalesSummary totals a range, split by tender, day and till.
|
||||||
|
func (ctl *PosController) GetSalesSummary(c *fiber.Ctx) error {
|
||||||
|
filter, err := posSalesFilter(c)
|
||||||
|
if err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := ctl.posService.SalesSummary(filter)
|
||||||
|
if err != nil {
|
||||||
|
return posServerError(c, "GetSalesSummary", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": summary})
|
||||||
|
}
|
||||||
|
|
||||||
|
func posBadRequest(c *fiber.Ctx, err error) error {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "message": err.Error(), "status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func posServerError(c *fiber.Ctx, op string, err error) error {
|
||||||
|
log.Printf("pos %s: %v", op, err)
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"code": http.StatusInternalServerError, "message": err.Error(), "status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// posIngestError decides whether the terminal should retry.
|
// posIngestError decides whether the terminal should retry.
|
||||||
//
|
//
|
||||||
// The distinction matters more than the message does. A misconfigured store id
|
// The distinction matters more than the message does. A misconfigured store id
|
||||||
@@ -217,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,
|
||||||
})
|
})
|
||||||
|
|||||||
458
docs/POS_API.md
Normal file
458
docs/POS_API.md
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
# POS integration — handover
|
||||||
|
|
||||||
|
Everything a developer needs to work on, extend or debug the in-store POS
|
||||||
|
integration. Companion to [`POS_TERMINAL_INGEST.md`](POS_TERMINAL_INGEST.md),
|
||||||
|
which covers deployment and broker setup.
|
||||||
|
|
||||||
|
Base path for everything below: **`/live/api/v1/pos`**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What this is
|
||||||
|
|
||||||
|
Retail tills run a Flutter POS app. Each one holds its own SQLite database and
|
||||||
|
keeps working with no network at all. When a connection is available it
|
||||||
|
publishes completed bills to an MQTT broker; a consumer in this backend commits
|
||||||
|
them to Postgres and acknowledges.
|
||||||
|
|
||||||
|
```
|
||||||
|
Cashier completes sale
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Till's SQLite ───────────────────── one transaction, before any network
|
||||||
|
sync_status = 0 survives crash, power cut, dead wifi
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
MQTT broker ──────────────────────── transit only, holds nothing you can rely on
|
||||||
|
nearle/pos/{loc}/{terminal}/order
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
fiesta consumer (messaging/posmqtt.go)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POSTGRES ─────────────────────────── the permanent record
|
||||||
|
pos_orders, pos_order_items
|
||||||
|
productstocks (stock deducted here)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ack → nearle/pos/{loc}/{terminal}/ack
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Till marks it synced, keeps its copy 7 more days, then purges
|
||||||
|
```
|
||||||
|
|
||||||
|
**Health** takes a separate path: every till publishes a heartbeat every 30
|
||||||
|
seconds, which lands in **Redis** under a 90-second TTL. Never in Postgres.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The four rules everything rests on
|
||||||
|
|
||||||
|
Break any of these and shops lose money. They are not stylistic.
|
||||||
|
|
||||||
|
**1. Only an application acknowledgement counts.**
|
||||||
|
A broker PUBACK means "I hold these bytes". It is not evidence the database
|
||||||
|
accepted anything. The ack is published *after* the transaction commits, never
|
||||||
|
from a handler that has merely queued the work.
|
||||||
|
|
||||||
|
**2. Silence is not acceptance.**
|
||||||
|
No ack, an empty ack, a 200 with no body — all leave the bill on the till, and
|
||||||
|
it is sent again. This is the correct behaviour when we are struggling.
|
||||||
|
|
||||||
|
**3. A duplicate is a success.**
|
||||||
|
Delivery is at-least-once. A lost ack makes a terminal re-send bills we already
|
||||||
|
hold. Reporting those as failures would strand a day of takings. Deduplication
|
||||||
|
is a unique index on `pos_orders.terminalorderid` — the UUID minted at the till
|
||||||
|
— plus a Postgres advisory lock held for the transaction.
|
||||||
|
|
||||||
|
**4. Store and terminal come from the topic, never the body.**
|
||||||
|
A till that could name its own store in a payload could redirect another
|
||||||
|
counter's acknowledgements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Endpoints written by a terminal
|
||||||
|
|
||||||
|
These answer with a **bare body**, not the usual `{code, message, status}`
|
||||||
|
envelope — the till reads `accepted` from the top level and marks a bill synced
|
||||||
|
only if its id is there. Wrapping it would leave every terminal queueing for
|
||||||
|
ever.
|
||||||
|
|
||||||
|
### `POST /orders`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"batch_id": "9f1c…",
|
||||||
|
"store_id": "1135",
|
||||||
|
"terminal_id": "T4A9",
|
||||||
|
"orders": [{
|
||||||
|
"id": "99999999-8888-4777-8666-555555555555",
|
||||||
|
"invoice_number": "INV-2608-T4A9-00002",
|
||||||
|
"created_at": "2026-08-03T17:29:00Z",
|
||||||
|
"cashier": "Divya",
|
||||||
|
"customer": {"id": "…", "mobile": "9840099999", "name": "Ravi"},
|
||||||
|
"subtotal": 60.0, "discount": 0.0, "tax": 4.44,
|
||||||
|
"tax_breakdown": {"0.08": 4.44},
|
||||||
|
"round_off": 0.0, "total": 60.0,
|
||||||
|
"points_earned": 0, "points_redeemed": 0,
|
||||||
|
"payments": [{"method": "upi", "amount": 60.0, "reference": "TXN123"}],
|
||||||
|
"items": [{
|
||||||
|
"product_id": "6988", "barcode": "6988", "name": "Mysore Banana",
|
||||||
|
"quantity": 1, "unit_price": 60.0, "discount": 0.0,
|
||||||
|
"gst_rate": 0.08, "tax": 4.44, "line_total": 60.0
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "batch_id": "9f1c…", "accepted": ["99999999-…"], "rejected": {} }
|
||||||
|
```
|
||||||
|
|
||||||
|
Status codes carry the other half of the contract:
|
||||||
|
|
||||||
|
| Code | Meaning | Terminal does |
|
||||||
|
|---|---|---|
|
||||||
|
| `200` | batch processed; ack says which bills landed | marks the named ids synced |
|
||||||
|
| `4xx` | the request is wrong — unknown outlet, bad store id | **halts** and shows a person |
|
||||||
|
| `5xx` | outcome unknown | keeps everything, retries with backoff |
|
||||||
|
|
||||||
|
### `POST /customers`
|
||||||
|
|
||||||
|
Same envelope with a `customers` array. **Insert-if-absent on id** — never an
|
||||||
|
update, so a profile corrected at head office is not reverted by a terminal
|
||||||
|
replaying an old capture.
|
||||||
|
|
||||||
|
The id is a **UUIDv5 over the normalised ten-digit mobile**, so two tills
|
||||||
|
registering the same shopper independently produce the same row. Do not
|
||||||
|
reassign it.
|
||||||
|
|
||||||
|
No loyalty figures travel upward — points and spend are derived from the bill
|
||||||
|
stream, which is idempotent and sees every counter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. GET endpoints — for the web app
|
||||||
|
|
||||||
|
**These use the normal `{code, message, status, details}` envelope.**
|
||||||
|
|
||||||
|
### `GET /sales` — bills for an outlet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "$BASE/sales?locationid=1135&fromdate=2026-08-01&todate=2026-08-03"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | |
|
||||||
|
|---|---|
|
||||||
|
| `locationid` | **required** — the authorisation boundary |
|
||||||
|
| `fromdate`, `todate` | `YYYY-MM-DD`, matched on `businessdate` |
|
||||||
|
| `terminalid` | e.g. `T4A9` |
|
||||||
|
| `cashiername` | exact match |
|
||||||
|
| `paymentmode` | `cash`, `card`, `upi`, `wallet` |
|
||||||
|
| `pageno` | 0-based, default 0 |
|
||||||
|
| `pagesize` | default 50, max 500 |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200, "status": true,
|
||||||
|
"details": {
|
||||||
|
"total": 137, "pageno": 0, "pagesize": 50,
|
||||||
|
"bills": [{
|
||||||
|
"posorderid": 7,
|
||||||
|
"terminalorderid": "99999999-8888-4777-8666-555555555555",
|
||||||
|
"invoicenumber": "INV-2608-T4A9-00002",
|
||||||
|
"tenantid": 1087, "locationid": 1135,
|
||||||
|
"terminalid": "T4A9", "cashiername": "Divya",
|
||||||
|
"customerid": 6847, "customermobile": "9840099999", "customername": "Ravi",
|
||||||
|
"billedat": "2026-08-03T17:29:00Z",
|
||||||
|
"businessdate": "2026-08-03",
|
||||||
|
"subtotal": 60, "discount": 0, "taxamount": 4.44,
|
||||||
|
"roundoff": 0, "total": 60,
|
||||||
|
"pointsearned": 0, "pointsredeemed": 0,
|
||||||
|
"itemcount": 1, "paymentmode": "upi",
|
||||||
|
"paymentsjson": "[{\"method\":\"upi\",\"amount\":60,\"reference\":\"TXN123\"}]",
|
||||||
|
"promosjson": "[]",
|
||||||
|
"taxbreakdownjson": "{\"0.08\":4.44}",
|
||||||
|
"batchid": "batch-mqtt-0001",
|
||||||
|
"receivedat": "2026-08-03T17:29:11Z"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Line items are **not** included — a page of 50 bills would drag hundreds of rows
|
||||||
|
behind it and a list screen shows none of them. Use `/sales/detail`.
|
||||||
|
|
||||||
|
Ordered by `billedat` descending, not by id: a backlog uploaded after an outage
|
||||||
|
arrives out of order, and sorting by arrival would interleave yesterday's bills
|
||||||
|
through today's.
|
||||||
|
|
||||||
|
### `GET /sales/detail` — one bill with its lines
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "$BASE/sales/detail?locationid=1135&reference=INV-2608-T4A9-00002"
|
||||||
|
```
|
||||||
|
|
||||||
|
`reference` accepts **any of three**: the terminal's order UUID, the invoice
|
||||||
|
number, or the `posorderid`. A support call starts from whichever the caller
|
||||||
|
happens to be looking at.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200, "status": true,
|
||||||
|
"details": {
|
||||||
|
"posorderid": 7,
|
||||||
|
"invoicenumber": "INV-2608-T4A9-00002",
|
||||||
|
"…": "all the fields above, plus:",
|
||||||
|
"items": [{
|
||||||
|
"posorderitemid": 12, "posorderid": 7,
|
||||||
|
"productid": 6988, "productname": "Mysore Banana",
|
||||||
|
"barcode": "6988", "unitname": "kg",
|
||||||
|
"quantity": 1, "unitprice": 60,
|
||||||
|
"discountamount": 0, "gstrate": 0.08,
|
||||||
|
"taxamount": 4.44, "linetotal": 60
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns **404** if the reference does not belong to that `locationid` — even
|
||||||
|
when the reference is a real bill at another outlet.
|
||||||
|
|
||||||
|
### `GET /sales/summary` — totals
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "$BASE/sales/summary?locationid=1135&fromdate=2026-08-01&todate=2026-08-03"
|
||||||
|
```
|
||||||
|
|
||||||
|
Takes the same filters as `/sales`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200, "status": true,
|
||||||
|
"details": {
|
||||||
|
"locationid": 1135,
|
||||||
|
"fromdate": "2026-08-01", "todate": "2026-08-03",
|
||||||
|
"billcount": 137, "itemcount": 402,
|
||||||
|
"grosssales": 18450.50, "taxcollected": 1204.30,
|
||||||
|
"discountgiven": 320.00, "roundoff": -1.50,
|
||||||
|
"averagebill": 134.68,
|
||||||
|
"bypaymentmode": [
|
||||||
|
{"paymentmode": "cash", "billcount": 80, "amount": 9200.00},
|
||||||
|
{"paymentmode": "upi", "billcount": 57, "amount": 9250.50}
|
||||||
|
],
|
||||||
|
"byday": [
|
||||||
|
{"businessdate": "2026-08-01", "billcount": 44, "amount": 5900.00}
|
||||||
|
],
|
||||||
|
"byterminal": [
|
||||||
|
{"terminalid": "T4A9", "billcount": 137, "amount": 18450.50}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Three breakdowns because they answer three different questions: **by tender**
|
||||||
|
for reconciling a drawer, **by day** for a chart, **by till** for an outlet
|
||||||
|
running several counters.
|
||||||
|
|
||||||
|
### `GET /health/terminal` — one till
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "$BASE/health/terminal?terminal_id=T4A9"
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200, "status": true,
|
||||||
|
"details": {
|
||||||
|
"terminal_id": "T4A9", "location_id": "1135",
|
||||||
|
"store_name": "Ragul stores Selvapuram",
|
||||||
|
"app_version": "1.1.0", "status": "online",
|
||||||
|
"pending_bills": "0", "pending_registrations": "0",
|
||||||
|
"oldest_pending_at": "",
|
||||||
|
"today_bills": "2", "today_amount": "170",
|
||||||
|
"last_bill_at": "",
|
||||||
|
"printer_reachable": "0",
|
||||||
|
"reported_at": "2026-08-03T12:04:21Z",
|
||||||
|
"received_at": "2026-08-03T12:04:21Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Values are **strings** — it is a Redis hash. A till that has not reported inside
|
||||||
|
its TTL returns `200` with `status: "offline"`, not a 404: it exists, it is
|
||||||
|
simply quiet.
|
||||||
|
|
||||||
|
Fields the till does not collect are **absent, not zero**. A board showing every
|
||||||
|
terminal at 0% battery is worse than one showing nothing.
|
||||||
|
|
||||||
|
`pending_bills` is the number worth watching. A shop quietly accumulating
|
||||||
|
unsynced takings looks completely normal from the floor.
|
||||||
|
|
||||||
|
### `GET /health/location` — the "which counters are dark" board
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "$BASE/health/location?location_id=1135"
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200, "status": true,
|
||||||
|
"details": {
|
||||||
|
"location_id": "1135", "total": 3, "online": 2,
|
||||||
|
"terminals": [
|
||||||
|
{"terminal_id": "T4A9", "status": "online", "today_bills": "37", "…": "…"},
|
||||||
|
{"terminal_id": "T7B2", "status": "offline", "reason": "no heartbeat within 90s"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A till whose key expired comes back marked **offline rather than omitted** —
|
||||||
|
omitting it would make a dead terminal indistinguishable from one that was never
|
||||||
|
installed, and the dead one is exactly what somebody is looking for.
|
||||||
|
|
||||||
|
### `GET /catalogue` — the till's product pull
|
||||||
|
|
||||||
|
Bare body, no envelope. Used by terminals, not the web app.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "$BASE/catalogue?store_id=1135&page_size=500"
|
||||||
|
curl "$BASE/catalogue?store_id=1135&since=loc1135-20260803T135407Z"
|
||||||
|
```
|
||||||
|
|
||||||
|
No `since` → **full snapshot**. With a valid `since` → **change set**.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"revision": "loc1135-20260803T135407Z",
|
||||||
|
"is_delta": false,
|
||||||
|
"has_more": false,
|
||||||
|
"products": [{
|
||||||
|
"id": "6988", "name": "Mysore Banana",
|
||||||
|
"barcode": "6988", "sku": "",
|
||||||
|
"category": "grocery", "price": 60, "stock": 750,
|
||||||
|
"unit": "kilogram", "gst_rate": 0.08, "is_active": true
|
||||||
|
}],
|
||||||
|
"customers": [],
|
||||||
|
"retired_product_ids": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`is_delta` is the dangerous field.** `false` means the terminal withdraws
|
||||||
|
every product the response does not mention. A filtered result labelled `false`
|
||||||
|
empties the shelf. In `Catalogue()` the filter and the flag are derived from one
|
||||||
|
value, so no code path can set one without the other.
|
||||||
|
|
||||||
|
Anything ambiguous resolves toward the snapshot: a revision that is malformed,
|
||||||
|
empty, or issued to a different outlet yields a full response.
|
||||||
|
|
||||||
|
The revision **only advances on the final page**, so a terminal that abandons a
|
||||||
|
paginated pull cannot end up holding one claiming it saw pages it never got.
|
||||||
|
|
||||||
|
A delta **cannot withdraw a deleted product** — removing a row from
|
||||||
|
`productlocations` leaves no tombstone. Only a snapshot collects those, so tills
|
||||||
|
should pull without a revision periodically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. MQTT topics
|
||||||
|
|
||||||
|
| Topic | Direction | Retained |
|
||||||
|
|---|---|---|
|
||||||
|
| `nearle/pos/{loc}/{terminal}/order` | till → us | no |
|
||||||
|
| `nearle/pos/{loc}/{terminal}/customer` | till → us | no |
|
||||||
|
| `nearle/pos/{loc}/{terminal}/health` | till → us, 30s | no |
|
||||||
|
| `nearle/pos/{loc}/{terminal}/ack` | us → till | no |
|
||||||
|
| `nearle/pos/{loc}/{terminal}/status` | till → us | **yes** (Last Will) |
|
||||||
|
| `nearle/pos/{loc}/catalogue` | us → all tills at a shop | **yes** |
|
||||||
|
|
||||||
|
`{loc}` is the numeric `tenantlocations.locationid`. The tenant is resolved from
|
||||||
|
it server-side and never taken from the wire.
|
||||||
|
|
||||||
|
Namespaced under `nearle/` alongside the rider fleet's `nearle/riders/…`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Database
|
||||||
|
|
||||||
|
**`pos_orders`** — one row per counter bill. Separate from `orders` because a
|
||||||
|
bill carries a cashier, terminal, rounding, promos, loyalty and a payment split
|
||||||
|
that `orders` has nowhere to put.
|
||||||
|
|
||||||
|
**`pos_order_items`** — one row per line.
|
||||||
|
|
||||||
|
**`productstocks`** — stock is **not** separate. A counter sale writes the same
|
||||||
|
`out` rows an app order does, through helpers in `repositories/stockLedger.go`.
|
||||||
|
Two stock ledgers would mean the catalogue pull sends a till figures that ignore
|
||||||
|
its own trading.
|
||||||
|
|
||||||
|
**`customers`** — registrations, matched on `contactno`.
|
||||||
|
|
||||||
|
**Redis** — `pos:terminal:{code}` (hash, 90s TTL) and
|
||||||
|
`pos:location:{id}:terminals` (set, no TTL). Namespaced `pos:*` so they cannot
|
||||||
|
collide with express's `delivery:*`, `city:*`, `rider_*`.
|
||||||
|
|
||||||
|
> **Reporting:** counter sales are unioned into `GetRevenueSummary` and
|
||||||
|
> `GetSalesSummary`. **Any new report must do the same**, or it will silently
|
||||||
|
> understate every shop that runs a till. That is the standing cost of the split.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Code map
|
||||||
|
|
||||||
|
| File | |
|
||||||
|
|---|---|
|
||||||
|
| `models/pos.go` | wire types — matches the till's JSON exactly |
|
||||||
|
| `models/posorder.go` | `pos_orders` / `pos_order_items` |
|
||||||
|
| `models/poshealth.go` | heartbeat |
|
||||||
|
| `repositories/posRepository.go` | ingest + catalogue |
|
||||||
|
| `repositories/posSalesRepository.go` | the GET reads |
|
||||||
|
| `repositories/posPresence.go` | Redis presence |
|
||||||
|
| `repositories/stockLedger.go` | **shared** stock helpers |
|
||||||
|
| `messaging/posmqtt.go` | MQTT consumer |
|
||||||
|
| `messaging/posworkers.go` | bounded worker pools |
|
||||||
|
| `controllers/posController.go` | HTTP handlers |
|
||||||
|
| `routes/posroutes.go` | routes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Operational notes
|
||||||
|
|
||||||
|
**Only `fiesta-0` consumes.** MQTT has no queue groups, so all replicas would
|
||||||
|
receive every message and commit the same bill three times. Ordinal 0 is
|
||||||
|
elected; the others log *"not the elected consumer"*. Override with
|
||||||
|
`POS_MQTT_CONSUMER=always|never`.
|
||||||
|
|
||||||
|
**Worker pools**: `POS_INGEST_WORKERS` (default 8), `POS_HEALTH_WORKERS`
|
||||||
|
(default 2). Heartbeats have their own pool so a backlog of bills cannot make
|
||||||
|
every till look dark at the busiest moment. A full queue **blocks**, pushing
|
||||||
|
backpressure to the broker and the till — slow, never lossy.
|
||||||
|
|
||||||
|
**Startup check**: three `pos: subscribed to nearle/pos/+/+/…` lines on
|
||||||
|
`fiesta-0`. Without them, MQTT ingest is not running and tills queue silently.
|
||||||
|
|
||||||
|
**The broker is not durable storage.** Mosquitto's `max_queued_messages` is 1000
|
||||||
|
and it flushes every 30 minutes. Fine, because a till keeps its copy until we
|
||||||
|
acknowledge — but nobody may ever ack on the broker's behalf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Known gaps
|
||||||
|
|
||||||
|
- **No product prices.** Every product at loc 1135 is ₹0, so nothing is
|
||||||
|
sellable. Unpriced products come down as `is_active: false` so a till cannot
|
||||||
|
ring up a ₹0 item.
|
||||||
|
- **The Flutter app has never been run.** All testing used a Go program
|
||||||
|
impersonating a till.
|
||||||
|
- **No TLS** on port 1883. Bills carry customer names and mobile numbers.
|
||||||
|
- **No device authentication.** A terminal is trusted with a location id.
|
||||||
|
- **Loyalty does not come back down.** Balances at a till are that till's view.
|
||||||
|
- **`productstocks.quantity` is an integer** but tills sell in kg. POS rounds
|
||||||
|
**up** so it never under-deducts; the app-order path truncates, which was left
|
||||||
|
alone rather than silently changed. Making the column numeric is the real fix.
|
||||||
|
- **Broker credentials in source** — `admin` is in the rider APK and still
|
||||||
|
unrestricted. The two POS accounts are the only ones not in a source tree.
|
||||||
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.
|
||||||
@@ -52,11 +52,41 @@ the standing cost of the split.
|
|||||||
|
|
||||||
### HTTP
|
### HTTP
|
||||||
|
|
||||||
|
Base path: `/live/api/v1/pos`
|
||||||
|
|
||||||
|
**Written by a terminal** — bare-ack responses, see below.
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST` | `/live/api/v1/pos/orders` | Completed bills |
|
| `POST` | `/orders` | Completed bills |
|
||||||
| `POST` | `/live/api/v1/pos/customers` | Shoppers registered at a till |
|
| `POST` | `/customers` | Shoppers registered at a till |
|
||||||
| `GET` | `/live/api/v1/pos/catalogue` | Product pull. Query: `store_id`, `since`, `page`, `page_size` |
|
| `GET` | `/catalogue` | Product pull. Query: `store_id`, `since`, `page`, `page_size` |
|
||||||
|
|
||||||
|
**Read by the web app** — normal `{code, message, status, details}` envelope.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/sales` | Bills for an outlet, newest first |
|
||||||
|
| `GET` | `/sales/detail` | One bill with its lines |
|
||||||
|
| `GET` | `/sales/summary` | Totals by tender, day and till |
|
||||||
|
| `GET` | `/health/terminal` | One till's live state |
|
||||||
|
| `GET` | `/health/location` | Every till at a shop |
|
||||||
|
|
||||||
|
`/sales` and `/sales/summary` take: **`locationid` (required)**, `fromdate`,
|
||||||
|
`todate` (YYYY-MM-DD, matched on `businessdate`), `terminalid`, `cashiername`,
|
||||||
|
`paymentmode`, `pageno`, `pagesize`.
|
||||||
|
|
||||||
|
`/sales/detail` takes `locationid` and `reference` — the terminal's order UUID,
|
||||||
|
the invoice number, or the `posorderid`, whichever the caller happens to have.
|
||||||
|
|
||||||
|
**`locationid` is the authorisation boundary.** Every read is scoped to one
|
||||||
|
outlet; omitting it is an error rather than a page through every shop's takings,
|
||||||
|
and asking for a bill under the wrong outlet returns 404 even when the reference
|
||||||
|
is valid.
|
||||||
|
|
||||||
|
Dates match `businessdate` — the day the sale was rung, not the day it reached
|
||||||
|
us. A till that was offline overnight uploads yesterday's bills this morning and
|
||||||
|
they belong to yesterday.
|
||||||
|
|
||||||
These answer with a **bare ack**, not the usual `{code, message, status}`
|
These answer with a **bare ack**, not the usual `{code, message, status}`
|
||||||
envelope — the terminal reads `accepted` from the top level of the body:
|
envelope — the terminal reads `accepted` from the top level of the body:
|
||||||
@@ -162,13 +192,45 @@ Worth knowing before the first bill lands.
|
|||||||
Registrations are **insert-if-absent** — never an update, so a profile
|
Registrations are **insert-if-absent** — never an update, so a profile
|
||||||
corrected at head office is not reverted by a terminal replaying an old
|
corrected at head office is not reverted by a terminal replaying an old
|
||||||
capture.
|
capture.
|
||||||
- **Catalogue** answers `is_delta: false` and is therefore a full snapshot. The
|
- **Catalogue** answers a snapshot or a change set, decided by the `since`
|
||||||
terminal withdraws every product a snapshot omits, so this must stay true
|
revision — see below.
|
||||||
while the query returns everything stocked at the outlet.
|
|
||||||
- **Barcodes** come from `products.productsku` — there is no barcode column.
|
- **Barcodes** come from `products.productsku` — there is no barcode column.
|
||||||
Scanning at the till matches on it, so SKUs must be the scannable code for
|
Scanning at the till matches on it, so SKUs must be the scannable code for
|
||||||
barcode scanning to work.
|
barcode scanning to work.
|
||||||
|
|
||||||
|
## Catalogue: snapshots and deltas
|
||||||
|
|
||||||
|
`GET /catalogue?store_id=1135` with no `since` returns a **full snapshot**. The
|
||||||
|
response carries a `revision`; the terminal stores it and sends it back next
|
||||||
|
time as `since=`, and then gets only what changed.
|
||||||
|
|
||||||
|
A product is included in a change set when any of three things moved: the
|
||||||
|
product row (name, tax, brand), its row at this outlet (price, availability), or
|
||||||
|
its stock ledger. Stock counts because a shop's figure drifts from a till's on
|
||||||
|
every sale rung at another counter, and a delta that ignored it would let that
|
||||||
|
drift persist until someone forced a full pull.
|
||||||
|
|
||||||
|
**The one rule that matters.** A response marked `is_delta: false` is treated as
|
||||||
|
a snapshot, and the terminal **withdraws every product it does not mention**. A
|
||||||
|
filtered result labelled `false` therefore empties the shop's shelf. The filter
|
||||||
|
and the flag are computed from a single value in `Catalogue()` — there is no
|
||||||
|
path that filters without also setting the flag, and that is deliberate.
|
||||||
|
|
||||||
|
**A revision that cannot be read falls back to a full snapshot.** Malformed,
|
||||||
|
empty, or issued to a different outlet — all yield a zero cutoff and a complete
|
||||||
|
response. The other direction would leave a terminal permanently missing every
|
||||||
|
change it had not already seen, with nothing to indicate it.
|
||||||
|
|
||||||
|
**The revision only advances on the final page.** A terminal that abandons a
|
||||||
|
paginated pull half way gets back the revision it already had — or an empty one,
|
||||||
|
meaning the next pull is a snapshot. Both are recoverable; a prematurely
|
||||||
|
advanced revision is not.
|
||||||
|
|
||||||
|
**A delta cannot withdraw a deleted product.** A row removed from
|
||||||
|
`productlocations` leaves no tombstone, so nothing tells the change set to
|
||||||
|
retire it. Only a snapshot collects those, which is why a terminal should pull
|
||||||
|
without a revision periodically — the morning import is the natural moment.
|
||||||
|
|
||||||
## Terminal health
|
## Terminal health
|
||||||
|
|
||||||
Every till publishes a heartbeat to `nearle/pos/{loc}/{terminal}/health` every
|
Every till publishes a heartbeat to `nearle/pos/{loc}/{terminal}/health` every
|
||||||
@@ -225,8 +287,6 @@ state.
|
|||||||
|
|
||||||
## Not built
|
## Not built
|
||||||
|
|
||||||
- **Catalogue deltas.** Every pull is a full snapshot. Fine for a few hundred
|
|
||||||
products, worth revisiting at a few thousand.
|
|
||||||
- **Loyalty coming back down.** The uplink deliberately carries no points or
|
- **Loyalty coming back down.** The uplink deliberately carries no points or
|
||||||
spend — those belong to the bill stream, which is idempotent and sees every
|
spend — those belong to the bill stream, which is idempotent and sees every
|
||||||
counter. Nothing yet computes them centrally and sends them to the tills, so
|
counter. Nothing yet computes them centrally and sends them to the tills, so
|
||||||
@@ -239,75 +299,56 @@ state.
|
|||||||
as zero: a board showing every till at 0% battery is worse than one showing
|
as zero: a board showing every till at 0% battery is worse than one showing
|
||||||
nothing.
|
nothing.
|
||||||
|
|
||||||
## Broker hardening — before a hundred tills join
|
## Broker accounts
|
||||||
|
|
||||||
Measured on the live broker, not assumed. None of this is caused by the POS
|
Applied 2026-08-03 on `66.116.225.226`. Two scoped accounts now exist alongside
|
||||||
work; all of it gets worse the moment bills start flowing.
|
`admin`, with an ACL at `/mosquitto/config/acl` referenced from
|
||||||
|
`mosquitto.conf`.
|
||||||
|
|
||||||
**There is no ACL file.** `allow_anonymous false` is set and auth is by password
|
| User | May publish | May subscribe |
|
||||||
file, but with no `acl_file` every authenticated user is unrestricted on every
|
|---|---|---|
|
||||||
topic. The rider app ships `admin` credentials **hardcoded in its APK**, so
|
| `pos_terminal` | `nearle/pos/+/+/{order,customer,status,health}` | `nearle/pos/+/+/{ack,command}`, `nearle/pos/+/catalogue` |
|
||||||
anyone who decompiles it today has full publish and subscribe over `nearle/#`
|
| `pos_ingest` | `nearle/pos/+/+/{ack,command}`, `nearle/pos/+/catalogue` | `nearle/pos/+/+/{order,customer,health,status}` |
|
||||||
*and* `doormile/#` — a second project's traffic. Adding POS puts every shop's
|
| `admin` | everything — **deliberately unchanged** | everything |
|
||||||
takings behind the same credential.
|
|
||||||
|
|
||||||
A scoped account is two commands and a container restart:
|
A till therefore cannot publish to `nearle/riders/#` or `doormile/#`, and cannot
|
||||||
|
write its own ack topic — only the ingest may do that. Verified by publishing as
|
||||||
|
`pos_terminal` to all four and watching which arrived: the order did, the other
|
||||||
|
three did not.
|
||||||
|
|
||||||
```bash
|
**`admin` was left unrestricted on purpose.** Its credentials are compiled into
|
||||||
# A user for the tills, and one for this backend.
|
the rider app, so narrowing it here would cut off the live rider fleet without
|
||||||
mosquitto_passwd -b /mosquitto/config/passwd pos_terminal '<strong-unique-pw>'
|
warning. The right next step is:
|
||||||
mosquitto_passwd -b /mosquitto/config/passwd pos_ingest '<different-pw>'
|
|
||||||
```
|
|
||||||
|
|
||||||
```conf
|
```conf
|
||||||
# /mosquitto/config/acl — then add `acl_file /mosquitto/config/acl` to mosquitto.conf
|
|
||||||
|
|
||||||
# Tills: publish their own traffic, read only their own acks and their shop's
|
|
||||||
# catalogue. The %c substitution binds a client to its own topics, so one till
|
|
||||||
# cannot read another's.
|
|
||||||
user pos_terminal
|
|
||||||
topic write nearle/pos/+/+/order
|
|
||||||
topic write nearle/pos/+/+/customer
|
|
||||||
topic write nearle/pos/+/+/status
|
|
||||||
topic write nearle/pos/+/+/health
|
|
||||||
topic read nearle/pos/+/+/ack
|
|
||||||
topic read nearle/pos/+/+/command
|
|
||||||
topic read nearle/pos/+/catalogue
|
|
||||||
|
|
||||||
# This backend: the mirror image.
|
|
||||||
user pos_ingest
|
|
||||||
topic read nearle/pos/+/+/order
|
|
||||||
topic read nearle/pos/+/+/customer
|
|
||||||
topic read nearle/pos/+/+/health
|
|
||||||
topic read nearle/pos/+/+/status
|
|
||||||
topic write nearle/pos/+/+/ack
|
|
||||||
topic write nearle/pos/+/+/command
|
|
||||||
topic write nearle/pos/+/catalogue
|
|
||||||
|
|
||||||
# Existing projects, scoped to what they already use.
|
|
||||||
user admin
|
user admin
|
||||||
topic readwrite nearle/riders/#
|
topic readwrite nearle/riders/#
|
||||||
topic readwrite doormile/#
|
topic readwrite doormile/#
|
||||||
```
|
```
|
||||||
|
|
||||||
Tighten `pos_terminal` further with per-terminal credentials if you want one
|
but only once someone has confirmed nothing else authenticates as `admin`.
|
||||||
till unable to read another's acks at all; the pattern above trusts tills within
|
Until then the ACL changes nothing for it — which is why applying it was safe.
|
||||||
the fleet but not outside it.
|
|
||||||
|
|
||||||
**There is no TLS.** Port 8883 is not configured and is closed. Rider GPS
|
Rollback, if ever needed:
|
||||||
travels in the clear today; POS bills carry customer names and mobile numbers,
|
|
||||||
which is a different category of exposure on a shared network. Adding a listener
|
|
||||||
means certs plus republishing the port, i.e. recreating the container — worth
|
|
||||||
doing before rollout rather than after.
|
|
||||||
|
|
||||||
**Two more, from the audit:**
|
```bash
|
||||||
|
cp /root/Mqtt/backup-<timestamp>/{mosquitto.conf,passwd} /root/Mqtt/config/
|
||||||
|
docker restart mqtt_broker
|
||||||
|
```
|
||||||
|
|
||||||
- The broker password and the workolik NATS password differ only in
|
**Still outstanding on the broker:**
|
||||||
capitalisation. Diverge them when creating the scoped users.
|
|
||||||
- Confirm on the host whether the broker was started from the compose file or
|
- **No TLS.** Port 8883 is not configured. Bills carry customer names and mobile
|
||||||
from a bare `docker run` before editing the compose file and expecting it to
|
numbers, and they travel in the clear. Traefik on the same host already
|
||||||
take effect — there is precedent in this estate for compose existing but not
|
terminates 443, so certificates exist to borrow from.
|
||||||
being the deploy path.
|
- **`passwd` is world-readable.** Mosquitto warns about it and future versions
|
||||||
|
will refuse to load it. Tightening it means `chown 1883:1883` as well as
|
||||||
|
`chmod`, because the broker runs as uid 1883 and a root-owned 0600 file would
|
||||||
|
stop it starting.
|
||||||
|
- **Credentials in source.** `admin` is in the rider APK, Redis is hardcoded in
|
||||||
|
the express backend, and Postgres was in this repository's git history until
|
||||||
|
2026-08-03. The POS accounts above are the only ones not in any source tree —
|
||||||
|
keep it that way.
|
||||||
|
|
||||||
## Capacity
|
## Capacity
|
||||||
|
|
||||||
95
docs/SECURITY_HANDOFF.md
Normal file
95
docs/SECURITY_HANDOFF.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Handoff: Broken Access Control (IDOR) audit & fixes — Fiesta backend
|
||||||
|
|
||||||
|
Repo: `backend_fiesta` (Go + Fiber + GORM), consumed by `nearledaily/daily_merchant_web` (React/TS) and a mobile app (not in this repo).
|
||||||
|
|
||||||
|
## 1. The root problem (still not fully fixed — read this first)
|
||||||
|
|
||||||
|
**There is no authentication system in this backend.** Grep confirms:
|
||||||
|
- No JWT/session token is ever issued. `Login`, `TenantLogin`, `TenantWebLogin`, `AppLogin` (in `controllers/userController.go`) just look up the user/tenant and return their info in the JSON body — no token.
|
||||||
|
- No auth middleware exists anywhere. `routes/routes.go` / `main.go` only wire up CORS middleware. Every route is wide open — anyone who can reach the API can call any endpoint with any query params.
|
||||||
|
|
||||||
|
Because of that, every endpoint trusts client-supplied query params (`tenantid`, `customerid`, `partnerid`, etc.) as the sole source of "who is asking." There is currently **nothing stopping a logged-in store admin for tenant 1135 from just requesting `?tenantid=1136`** and getting another tenant's data — the frontend happens to always send the logged-in user's own tenantid, but the backend never checks it.
|
||||||
|
|
||||||
|
**This session's fixes only close one specific hole**, described below. The real fix — deriving identity server-side from a verified token instead of trusting query params — has not been started. Whoever picks this up should treat that as the actual next milestone.
|
||||||
|
|
||||||
|
## 2. The specific bug that was found and fixed this session
|
||||||
|
|
||||||
|
Pattern found repeatedly across the codebase: repository functions build SQL dynamically, e.g.
|
||||||
|
|
||||||
|
```go
|
||||||
|
query := "SELECT ... FROM orders WHERE 1=1"
|
||||||
|
if tenantID != 0 {
|
||||||
|
query += " AND tenantid = ?"
|
||||||
|
params = append(params, tenantID)
|
||||||
|
}
|
||||||
|
// ...similar optional blocks for partnerid, customerid, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
**If none of the scoping params were supplied (0 / empty), the query silently fell through to "no WHERE clause" and returned every row in the table across every tenant.** This was directly reachable — e.g. `orders/getorders` with no `tenantid` returned all ~300 orders in the DB rather than 400ing, which is how the user first noticed this (logged in as a store admin, expected only their store's orders, saw everyone's).
|
||||||
|
|
||||||
|
### Fix pattern applied
|
||||||
|
|
||||||
|
Rather than rewriting every repository query (large surface area, higher regression risk), a **controller-level guard** was added to each affected endpoint: if none of the valid scoping ids are present in the query string, return `400` immediately instead of calling the service/repo at all.
|
||||||
|
|
||||||
|
Standard error shape used everywhere:
|
||||||
|
```json
|
||||||
|
{ "status": false, "code": 400, "message": "<specific message>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Endpoints fixed (8 total)
|
||||||
|
|
||||||
|
| # | Endpoint | File / function | Guard added |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `GET /v1/web/orders/getorders` (+ mob) | `controllers/orderController.go` `GetOrders` (line 24) | requires one of `tenantid`, `partnerid`, `customerid`, `applocationid`, `appuserid` — else 400 (line ~102-110). Previously the `else` branch called `GetAllOrders` (unscoped). |
|
||||||
|
| 2 | `GET /v1/web/orders/getordersummary` | `controllers/orderController.go` `GetOrderSummary` (line 129) | requires one of `tenantid`, `partnerid`, `customerid`, `locationid` (line 137-143) |
|
||||||
|
| 3 | `GET /v1/web/orders/getlocationsummary` | `controllers/orderController.go` `GetlocationOrderSummary` (line 163) | requires `tenantid` (line 167-173) |
|
||||||
|
| 4 | `GET /v1/web/users/getallusers` | `controllers/userController.go` `GetAllUsers` (line 22) | requires `tenantid` (line 29-35). Note: this endpoint's query selects `a.pin` (login PIN) — this was a high-severity leak (PINs across all tenants) before the fix. |
|
||||||
|
| 5 | `GET /v1/web/deliveries/getdeliveries` (+ mob) | `controllers/deliveriesController.go` `GetDeliveries` (line 194) | requires one of `tenantid`, `partnerid`, `customerid`, `applocationid`, `userid`, `appuserid` (line 212-218) |
|
||||||
|
| 6 | `GET /v1/web/partners/getriders` (+ mob) | `controllers/partnerController.go` `GetActiveRiders` (line 19) | requires one of `tenantid`, `partnerid`, `applocationid`, `userid` (line 25-31). Lower severity — underlying repo query defaults to `userid = 0` rather than a full dump, but fixed for consistency. |
|
||||||
|
| 7 | `GET /v1/web/partners/getriderlogs` (+ mob) | `controllers/partnerController.go` `GetRiderLogs` (line 121) | requires one of `partnerid`, `applocationid` (line 127-133). **Also fixed an unrelated bug in the same function**: `tdate` was reading `c.Query("fromdate")` (copy-paste error) so the end of any date range was always silently overwritten with the start date. Now correctly reads `c.Query("todate")` (line 125). |
|
||||||
|
| 8 | `POST /v1/mob/orders/getcustomerorders` | `controllers/orderController.go` `GetCustomerOrders` (line 374) | requires `customerid` (line 394-400) |
|
||||||
|
|
||||||
|
### Also fixed alongside #2: SQL injection in `GetOrderSummary`
|
||||||
|
|
||||||
|
`repositories/orderRepository.go` `GetOrderSummary` previously built the date filter by **string-concatenating** `fdate`/`tdate` directly into raw SQL. Rewritten to use parameterized `?` placeholders passed through `r.db.Raw(query, params...)`. The `strconv` import was removed from that file since it became unused after the rewrite (verified via grep no other usage remained).
|
||||||
|
|
||||||
|
## 4. Reviewed and explicitly NOT changed (don't re-flag these)
|
||||||
|
|
||||||
|
Same `WHERE 1=1` pattern exists elsewhere but was judged not to be a bug, or already safe:
|
||||||
|
|
||||||
|
- **`repositories/tenantRepository.go` `GetAllTenants`** — intentionally lists all tenants for a platform/super-admin console. The gap here is "no RBAC to restrict who can call this," which is the same root-cause auth gap from section 1, not a scoping bug to patch individually.
|
||||||
|
- **`repositories/productRepository.go` `GetProductSubCategory`** — has an explanatory comment: subcategories are intentionally shared/global master data plus tenant-owned overrides. Not a bug.
|
||||||
|
- **`repositories/productRepository.go` `GetProductCount`** — returns aggregate counts only (no PII), low severity, left as-is.
|
||||||
|
- **`repositories/utilsRepository.go` `GetSubcategories`** — global taxonomy/reference data; the model has no tenant field at all.
|
||||||
|
- **`repositories/orderRepository.go` `GetAdminOrders`** — has `WHERE 1=1` internally but is safe because its only caller (`GetOrders` controller) only invokes it when `applocationid != 0`.
|
||||||
|
- **`repositories/orderRepository.go` `GetAllOrders`** — now dead code (unreachable) after fix #1 above; confirmed via grep it's no longer called anywhere. Could be deleted as cleanup but left in place.
|
||||||
|
- **`repositories/tenantRepository.go` `GetTenantLocations`** — already always filters `WHERE tenantid = ?`. Safe, unchanged.
|
||||||
|
|
||||||
|
## 5. Known pre-existing bug found during this audit, NOT yet fixed anywhere
|
||||||
|
|
||||||
|
**Frontend/backend path mismatch on rider logs.** In `nearledaily/daily_merchant_web/src/services/fiestaApi.ts`, `getRiderLogs()` (~line 1132) calls:
|
||||||
|
```ts
|
||||||
|
fiestaGet('riders/getriderlogs', {...})
|
||||||
|
```
|
||||||
|
`FIESTA_BASE` is `https://fiesta.nearle.app/live/api/v1/web`, so this resolves to `.../v1/web/riders/getriderlogs`. But the backend only registers this route under the `partners` group (`routes/partnerroutes.go`): `partner.Get("/getriderlogs", ...)` on `api.Group("/v1/web/partners")`, i.e. the real path is `.../v1/web/partners/getriderlogs`. Confirmed via grep there is no `/v1/web/riders` route group anywhere in the backend.
|
||||||
|
|
||||||
|
**This means `getRiderLogs()` in the web console has likely been 404ing already, independent of anything fixed this session.** Fix is a one-line FE change: `'riders/getriderlogs'` → `'partners/getriderlogs'`. Not fixed yet because it's a frontend-repo change and wasn't the scope of this backend security pass — flagging it here so it isn't lost.
|
||||||
|
|
||||||
|
## 6. Frontend compatibility check (already done, no FE changes needed for the 8 fixes above)
|
||||||
|
|
||||||
|
Checked `daily_merchant_web/src/services/fiestaApi.ts` against every fix — the web console already sends the now-required params in all cases:
|
||||||
|
- `getOrders`, `getAllUsers`, `getDeliveries`, `getOrderSummary`, `getLocationSummary` — all declare `tenantid: number` as a **required** (non-optional) TS field already.
|
||||||
|
- `getRiders` — always sends `applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID` (never zero/undefined) plus required `tenantid`.
|
||||||
|
- `getRiderLogs` — sends `tenantid`/`applocationid` when available, but see the path bug in section 5 — worth re-verifying once that's fixed.
|
||||||
|
- **`mob/orders/getcustomerorders`** (fix #8) is called from the **mobile app**, which is not in this repo — whoever owns that codebase needs to verify every call site always sends `customerid`. Not verified in this session.
|
||||||
|
|
||||||
|
## 7. Environment note
|
||||||
|
|
||||||
|
**No Go toolchain is available in the sandbox this session ran in** (`command not found: go`). All edits above were manually reviewed (imports, syntax, call sites checked via Read/grep) but **never compiled**. Run `go build ./...` and the existing test suite (if any) before deploying any of this.
|
||||||
|
|
||||||
|
## 8. Suggested next steps for whoever picks this up
|
||||||
|
|
||||||
|
1. `go build ./...` and smoke-test all 8 changed endpoints (call with and without the required param, confirm 200 vs 400).
|
||||||
|
2. Fix the `riders/getriderlogs` → `partners/getriderlogs` path bug in `fiestaApi.ts` (section 5).
|
||||||
|
3. Verify the mobile app always sends `customerid` to `mob/orders/getcustomerorders` before this ships, since that's the one fixed endpoint not verified from a frontend contract.
|
||||||
|
4. Scope and plan the real fix: JWT/session auth issuance + middleware, so `tenantid`/`customerid`/etc. are derived from a verified server-side identity instead of trusted from query params. Until that lands, the 8 fixes in this doc only prevent the "forgot to pass an id → get everything" failure mode — they do **not** prevent a malicious or buggy client from passing a *different* tenant's/customer's/partner's real id and getting their data.
|
||||||
@@ -16,22 +16,17 @@ import (
|
|||||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Plain-MQTT ingest for the Nearle POS terminals.
|
// MQTT ingest for the Nearle POS terminals.
|
||||||
//
|
//
|
||||||
// The sibling of posconsumer.go, and which one you want depends entirely on
|
// The broker is Eclipse Mosquitto, shared with the rider fleet. An audit of the
|
||||||
// what is listening on the other end:
|
// estate found no reachable NATS and no MQTT gateway on the NATS boxes that do
|
||||||
|
// exist, so a NATS consumer that briefly lived here was deleted rather than
|
||||||
|
// left to rot — a client for a protocol nothing speaks is worse than none.
|
||||||
//
|
//
|
||||||
// - **This file** talks MQTT to a broker like Mosquitto or EMQX — the kind
|
// Enabled with MQTT_URL. Unset, the terminals reach the same service over HTTP
|
||||||
// already running at the rider app's `66.116.225.226:1883`.
|
// instead, and this file does nothing.
|
||||||
// - **posconsumer.go** talks the NATS protocol to a NATS server, which
|
|
||||||
// exposes MQTT through a gateway but speaks NATS itself on 4222.
|
|
||||||
//
|
//
|
||||||
// They are not interchangeable: a NATS client cannot connect to Mosquitto, and
|
// Only one replica consumes: see posConsumerElected.
|
||||||
// an MQTT client cannot use NATS' native subjects. Both call the same
|
|
||||||
// PosService, so whichever is running, a bill lands identically.
|
|
||||||
//
|
|
||||||
// Enabled with MQTT_URL. Both may run at once, which is what a migration
|
|
||||||
// between brokers looks like.
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Namespaced under `nearle/` alongside the rider app's
|
// Namespaced under `nearle/` alongside the rider app's
|
||||||
@@ -47,6 +42,12 @@ const (
|
|||||||
type PosMqttConsumer struct {
|
type PosMqttConsumer struct {
|
||||||
client mqtt.Client
|
client mqtt.Client
|
||||||
svc services.PosService
|
svc services.PosService
|
||||||
|
|
||||||
|
// Bills and registrations share a pool; heartbeats get their own, so a
|
||||||
|
// backlog of sales cannot make every till look dark at the moment the
|
||||||
|
// system is busiest.
|
||||||
|
ingest *posPool
|
||||||
|
health *posPool
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartPosMqttConsumer connects and subscribes.
|
// StartPosMqttConsumer connects and subscribes.
|
||||||
@@ -60,14 +61,47 @@ func StartPosMqttConsumer(svc services.PosService) (*PosMqttConsumer, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
c := &PosMqttConsumer{svc: svc}
|
// Only one replica consumes.
|
||||||
|
//
|
||||||
|
// MQTT has no queue groups — every subscriber receives every message, so
|
||||||
|
// three replicas would each commit the same bill and publish three acks.
|
||||||
|
// The ingest is idempotent, so nothing double-counts, but it is three times
|
||||||
|
// the database work and three times the traffic for one sale.
|
||||||
|
//
|
||||||
|
// A StatefulSet gives pods stable ordinal names, so ordinal 0 is a
|
||||||
|
// deterministic election with no coordination and no extra dependency. If
|
||||||
|
// that pod dies the set recreates it; tills hold their bills and re-send in
|
||||||
|
// the meantime, which is exactly what they are built to do.
|
||||||
|
if !posConsumerElected() {
|
||||||
|
log.Printf("pos: replica %q is not the elected consumer, MQTT ingest idle here",
|
||||||
|
os.Getenv("HOSTNAME"))
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each ingest worker holds a database transaction while it runs, so the
|
||||||
|
// real ceiling is the Postgres connection pool rather than the CPU. The
|
||||||
|
// queue is deep enough to absorb a burst and shallow enough that a genuine
|
||||||
|
// overload is felt as backpressure rather than hidden as latency.
|
||||||
|
c := &PosMqttConsumer{
|
||||||
|
svc: svc,
|
||||||
|
ingest: newPosPool("ingest", posPoolSize("POS_INGEST_WORKERS", 8), 256),
|
||||||
|
health: newPosPool("health", posPoolSize("POS_HEALTH_WORKERS", 2), 512),
|
||||||
|
}
|
||||||
|
|
||||||
opts := mqtt.NewClientOptions().
|
opts := mqtt.NewClientOptions().
|
||||||
AddBroker(url).
|
AddBroker(url).
|
||||||
// Stable, so the broker resumes this session and redelivers anything
|
// Stable, so the broker resumes this session and redelivers anything
|
||||||
// in flight rather than treating every restart as a new subscriber.
|
// in flight rather than treating every restart as a new subscriber.
|
||||||
SetClientID(getEnvDefault("MQTT_CLIENT_ID", "nearle-pos-ingest")).
|
// Defaults to the pod name so replicas can never collide: a second
|
||||||
|
// connection with the same client id evicts the first, and the two then
|
||||||
|
// fight in a reconnect loop that looks like a flapping network.
|
||||||
|
SetClientID(getEnvDefault("MQTT_CLIENT_ID",
|
||||||
|
getEnvDefault("HOSTNAME", "nearle-pos-ingest"))).
|
||||||
SetCleanSession(false).
|
SetCleanSession(false).
|
||||||
|
// Ordered delivery keeps paho on one goroutine, which is what lets a
|
||||||
|
// full queue push back on the broker. With concurrent delivery paho
|
||||||
|
// would keep reading no matter how far behind the workers were.
|
||||||
|
SetOrderMatters(posOrderedDelivery).
|
||||||
SetAutoReconnect(true).
|
SetAutoReconnect(true).
|
||||||
SetMaxReconnectInterval(30 * time.Second).
|
SetMaxReconnectInterval(30 * time.Second).
|
||||||
SetKeepAlive(30 * time.Second).
|
SetKeepAlive(30 * time.Second).
|
||||||
@@ -85,9 +119,9 @@ func StartPosMqttConsumer(svc services.PosService) (*PosMqttConsumer, error) {
|
|||||||
opts.SetOnConnectHandler(func(client mqtt.Client) {
|
opts.SetOnConnectHandler(func(client mqtt.Client) {
|
||||||
log.Printf("pos: connected to MQTT broker %s", url)
|
log.Printf("pos: connected to MQTT broker %s", url)
|
||||||
for topic, handler := range map[string]mqtt.MessageHandler{
|
for topic, handler := range map[string]mqtt.MessageHandler{
|
||||||
topicOrders: c.handleOrders,
|
topicOrders: wrapHandler(c.ingest, c.handleOrders),
|
||||||
topicCustomers: c.handleCustomers,
|
topicCustomers: wrapHandler(c.ingest, c.handleCustomers),
|
||||||
topicHealth: c.handleHealth,
|
topicHealth: wrapHandler(c.health, c.handleHealth),
|
||||||
} {
|
} {
|
||||||
if token := client.Subscribe(topic, 1, handler); token.Wait() && token.Error() != nil {
|
if token := client.Subscribe(topic, 1, handler); token.Wait() && token.Error() != nil {
|
||||||
log.Printf("pos: could not subscribe to %s: %v", topic, token.Error())
|
log.Printf("pos: could not subscribe to %s: %v", topic, token.Error())
|
||||||
@@ -254,6 +288,12 @@ func (c *PosMqttConsumer) Close() {
|
|||||||
if c == nil || c.client == nil {
|
if c == nil || c.client == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Workers drain before the connection closes, so a bill mid-commit still
|
||||||
|
// gets its ack out. Disconnecting first would strand it: committed here,
|
||||||
|
// unacknowledged there, and sent again on the till's next attempt.
|
||||||
|
c.ingest.stop()
|
||||||
|
c.health.stop()
|
||||||
|
|
||||||
quiesce, err := strconv.Atoi(getEnvDefault("MQTT_QUIESCE_MS", "2000"))
|
quiesce, err := strconv.Atoi(getEnvDefault("MQTT_QUIESCE_MS", "2000"))
|
||||||
if err != nil || quiesce < 0 {
|
if err != nil || quiesce < 0 {
|
||||||
quiesce = 2000
|
quiesce = 2000
|
||||||
@@ -261,6 +301,37 @@ func (c *PosMqttConsumer) Close() {
|
|||||||
c.client.Disconnect(uint(quiesce))
|
c.client.Disconnect(uint(quiesce))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// posConsumerElected decides whether this replica runs the MQTT ingest.
|
||||||
|
//
|
||||||
|
// Rules, in order:
|
||||||
|
//
|
||||||
|
// - POS_MQTT_CONSUMER=always or =never settles it outright, for deployments
|
||||||
|
// that are not a StatefulSet or that want the consumer somewhere specific.
|
||||||
|
// - A StatefulSet pod name ending in `-0` is elected. Ordinals are stable and
|
||||||
|
// unique, so this needs no lock, no lease and no coordination.
|
||||||
|
// - Anything else — a bare container, a Deployment, local development —
|
||||||
|
// is elected, because a single instance that refused to consume would be a
|
||||||
|
// far more confusing failure than one that did.
|
||||||
|
func posConsumerElected() bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(os.Getenv("POS_MQTT_CONSUMER"))) {
|
||||||
|
case "always", "true", "yes":
|
||||||
|
return true
|
||||||
|
case "never", "false", "no":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
host := strings.TrimSpace(os.Getenv("HOSTNAME"))
|
||||||
|
if i := strings.LastIndex(host, "-"); i >= 0 {
|
||||||
|
if ordinal := host[i+1:]; ordinal != "" && strings.Trim(ordinal, "0123456789") == "" {
|
||||||
|
// A StatefulSet ordinal. Only the first replica consumes.
|
||||||
|
return ordinal == "0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not an ordinal-named pod, so there is nothing to elect against.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func getEnvDefault(key, fallback string) string {
|
func getEnvDefault(key, fallback string) string {
|
||||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -56,6 +56,57 @@ func (f *fakePosService) TerminalHealth(context.Context, string) (map[string]str
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakePosService) Sales(models.PosSalesFilter) (*models.PosSalesPage, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePosService) SaleDetail(int, string) (*models.PosOrders, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePosService) SalesSummary(models.PosSalesFilter) (*models.PosSalesSummary, error) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -100,9 +151,9 @@ func (c *fakeClient) Subscribe(string, byte, mqtt.MessageHandler) mqtt.Token {
|
|||||||
func (c *fakeClient) SubscribeMultiple(map[string]byte, mqtt.MessageHandler) mqtt.Token {
|
func (c *fakeClient) SubscribeMultiple(map[string]byte, mqtt.MessageHandler) mqtt.Token {
|
||||||
return doneToken{}
|
return doneToken{}
|
||||||
}
|
}
|
||||||
func (c *fakeClient) Unsubscribe(...string) mqtt.Token { return doneToken{} }
|
func (c *fakeClient) Unsubscribe(...string) mqtt.Token { return doneToken{} }
|
||||||
func (c *fakeClient) AddRoute(string, mqtt.MessageHandler) {}
|
func (c *fakeClient) AddRoute(string, mqtt.MessageHandler) {}
|
||||||
func (c *fakeClient) OptionsReader() mqtt.ClientOptionsReader { return mqtt.ClientOptionsReader{} }
|
func (c *fakeClient) OptionsReader() mqtt.ClientOptionsReader { return mqtt.ClientOptionsReader{} }
|
||||||
|
|
||||||
type doneToken struct{}
|
type doneToken struct{}
|
||||||
|
|
||||||
@@ -368,3 +419,44 @@ func TestTopicIdentityRejectsShortTopics(t *testing.T) {
|
|||||||
t.Errorf("topicIdentity = %q/%q, want 12/T4A9", store, terminal)
|
t.Errorf("topicIdentity = %q/%q, want 12/T4A9", store, terminal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MQTT has no queue groups: every subscriber gets every message. Three replicas
|
||||||
|
// all consuming would commit the same bill three times and publish three acks —
|
||||||
|
// harmless, because the ingest is idempotent, but three times the work.
|
||||||
|
func TestOnlyTheFirstStatefulSetReplicaConsumes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
hostname string
|
||||||
|
override string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"statefulset ordinal 0", "fiesta-0", "", true},
|
||||||
|
{"statefulset ordinal 1", "fiesta-1", "", false},
|
||||||
|
{"statefulset ordinal 2", "fiesta-2", "", false},
|
||||||
|
{"double-digit ordinal", "fiesta-10", "", false},
|
||||||
|
|
||||||
|
// A Deployment pod has a random suffix, not an ordinal. Refusing to
|
||||||
|
// consume there would be a far more confusing failure than consuming.
|
||||||
|
{"deployment pod", "fiesta-7d4f9c8b6d-x2k9p", "", true},
|
||||||
|
{"bare container", "a1b2c3d4e5f6", "", true},
|
||||||
|
{"no hostname", "", "", true},
|
||||||
|
|
||||||
|
// The override settles it outright either way.
|
||||||
|
{"forced on", "fiesta-2", "always", true},
|
||||||
|
{"forced off", "fiesta-0", "never", false},
|
||||||
|
{"forced on via true", "fiesta-5", "true", true},
|
||||||
|
{"forced off via false", "fiesta-0", "false", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
t.Setenv("HOSTNAME", c.hostname)
|
||||||
|
t.Setenv("POS_MQTT_CONSUMER", c.override)
|
||||||
|
|
||||||
|
if got := posConsumerElected(); got != c.want {
|
||||||
|
t.Errorf("posConsumerElected() = %v, want %v (hostname %q, override %q)",
|
||||||
|
got, c.want, c.hostname, c.override)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
174
messaging/posworkers.go
Normal file
174
messaging/posworkers.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Concurrency for the MQTT ingest.
|
||||||
|
//
|
||||||
|
// paho delivers messages on a single goroutine, so without this every bill is
|
||||||
|
// committed one after another. A bill is a full Postgres transaction — advisory
|
||||||
|
// lock, dedup check, stock row locks, availability check, four inserts, commit —
|
||||||
|
// which realistically costs 10–30ms. Serially that is 30–100 bills a second,
|
||||||
|
// and a shop-wide backlog draining after an outage would take minutes to land.
|
||||||
|
//
|
||||||
|
// ### Why a bounded pool rather than a goroutine per message
|
||||||
|
//
|
||||||
|
// paho can be told to call handlers concurrently, but it spawns without limit.
|
||||||
|
// A storm would then open a database transaction per message, exhaust the
|
||||||
|
// connection pool, and stall every one of them at once — turning a slow minute
|
||||||
|
// into a dead one.
|
||||||
|
//
|
||||||
|
// A fixed pool behind a bounded queue does the opposite. When the queue fills,
|
||||||
|
// submitting **blocks**, which is the point: paho stops acknowledging, the
|
||||||
|
// broker's in-flight window fills, and it stops sending. Backpressure travels
|
||||||
|
// all the way back to the till, which holds its bills and retries. Slow, but
|
||||||
|
// nothing is dropped and nothing is lost.
|
||||||
|
//
|
||||||
|
// ### Why bills and heartbeats have separate pools
|
||||||
|
//
|
||||||
|
// A heartbeat is one Redis write and a bill is a transaction. Sharing a queue
|
||||||
|
// would let a backlog of bills delay presence, and every till would appear to
|
||||||
|
// go dark at exactly the moment the system was busiest — the worst possible
|
||||||
|
// time to be blind to which counters are alive.
|
||||||
|
|
||||||
|
// posPool is a fixed set of workers reading a bounded queue.
|
||||||
|
type posPool struct {
|
||||||
|
name string
|
||||||
|
jobs chan func()
|
||||||
|
wg sync.WaitGroup
|
||||||
|
once sync.Once
|
||||||
|
|
||||||
|
// Guards the transition to closed. A plain `select` over a done-channel and
|
||||||
|
// the job channel is not enough: once both are ready Go picks between them
|
||||||
|
// at random, and picking the send panics on a closed channel. Held for
|
||||||
|
// reading across the whole of submit, so stop cannot close the queue out
|
||||||
|
// from under a send already in progress.
|
||||||
|
mu sync.RWMutex
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPosPool(name string, workers, queue int) *posPool {
|
||||||
|
p := &posPool{
|
||||||
|
name: name,
|
||||||
|
jobs: make(chan func(), queue),
|
||||||
|
}
|
||||||
|
|
||||||
|
p.wg.Add(workers)
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
go func() {
|
||||||
|
defer p.wg.Done()
|
||||||
|
for job := range p.jobs {
|
||||||
|
job()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("pos: %s pool started with %d workers, queue %d", name, workers, queue)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// submit queues work, blocking when the queue is full.
|
||||||
|
//
|
||||||
|
// Blocking is deliberate. Dropping would lose a bill outright; the terminal
|
||||||
|
// would eventually re-send it, but only after its ack timeout, and meanwhile we
|
||||||
|
// would have thrown away work we had already accepted. Blocking instead pushes
|
||||||
|
// back through paho to the broker to the till, which is exactly where the
|
||||||
|
// decision to slow down belongs.
|
||||||
|
func (p *posPool) submit(job func()) {
|
||||||
|
p.mu.RLock()
|
||||||
|
|
||||||
|
if p.closed {
|
||||||
|
p.mu.RUnlock()
|
||||||
|
// Shutting down. Running it inline still gets the work done and its ack
|
||||||
|
// published, rather than discarding a bill that already reached us.
|
||||||
|
job()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The read lock is held across the send. Blocking here while the queue is
|
||||||
|
// full cannot deadlock against stop: the workers only exit once the channel
|
||||||
|
// is closed, and that happens under the write lock this send is holding
|
||||||
|
// off — so they stay alive and keep draining until this send completes.
|
||||||
|
p.jobs <- job
|
||||||
|
p.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop drains the queue and waits for in-flight work.
|
||||||
|
//
|
||||||
|
// Every job already accepted runs to completion, so a bill mid-commit still
|
||||||
|
// gets its ack. Without one the terminal would hold it and send it again on
|
||||||
|
// restart — harmless, but avoidable.
|
||||||
|
func (p *posPool) stop() {
|
||||||
|
p.once.Do(func() {
|
||||||
|
// The write lock waits for every submit already in progress, so the
|
||||||
|
// channel is never closed while something is mid-send.
|
||||||
|
p.mu.Lock()
|
||||||
|
p.closed = true
|
||||||
|
close(p.jobs)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
p.wg.Wait()
|
||||||
|
log.Printf("pos: %s pool drained", p.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// posPoolSize reads a worker count from the environment.
|
||||||
|
//
|
||||||
|
// The default is deliberately modest. Each worker holds a database transaction
|
||||||
|
// while it runs, so the useful ceiling is the Postgres connection pool, not the
|
||||||
|
// CPU — set this above what the database can serve and the workers simply queue
|
||||||
|
// inside the driver instead, where there is no backpressure to feel.
|
||||||
|
func posPoolSize(key string, fallback int) int {
|
||||||
|
v, err := strconv.Atoi(os.Getenv(key))
|
||||||
|
if err != nil || v <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if v > 128 {
|
||||||
|
return 128
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// posOrderedDelivery reports whether paho should preserve message order.
|
||||||
|
//
|
||||||
|
// Left on: paho then delivers on one goroutine, which hands work to the pool
|
||||||
|
// and blocks when it is full. That single delivery goroutine is what makes
|
||||||
|
// backpressure reach the broker at all — with concurrent delivery paho would
|
||||||
|
// keep reading regardless of how far behind the workers were.
|
||||||
|
const posOrderedDelivery = true
|
||||||
|
|
||||||
|
// wrapHandler puts a paho message handler behind a pool.
|
||||||
|
//
|
||||||
|
// The payload is copied because paho reuses its buffer once the handler
|
||||||
|
// returns, and the work now happens after that.
|
||||||
|
func wrapHandler(pool *posPool, h mqtt.MessageHandler) mqtt.MessageHandler {
|
||||||
|
return func(client mqtt.Client, msg mqtt.Message) {
|
||||||
|
topic := msg.Topic()
|
||||||
|
payload := make([]byte, len(msg.Payload()))
|
||||||
|
copy(payload, msg.Payload())
|
||||||
|
|
||||||
|
pool.submit(func() {
|
||||||
|
h(client, copiedMessage{topic: topic, payload: payload})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copiedMessage carries a payload that outlives paho's buffer.
|
||||||
|
type copiedMessage struct {
|
||||||
|
topic string
|
||||||
|
payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m copiedMessage) Duplicate() bool { return false }
|
||||||
|
func (m copiedMessage) Qos() byte { return 1 }
|
||||||
|
func (m copiedMessage) Retained() bool { return false }
|
||||||
|
func (m copiedMessage) Topic() string { return m.topic }
|
||||||
|
func (m copiedMessage) MessageID() uint16 { return 0 }
|
||||||
|
func (m copiedMessage) Payload() []byte { return m.payload }
|
||||||
|
func (m copiedMessage) Ack() {}
|
||||||
247
messaging/posworkers_test.go
Normal file
247
messaging/posworkers_test.go
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEveryJobRuns(t *testing.T) {
|
||||||
|
pool := newPosPool("test", 4, 16)
|
||||||
|
|
||||||
|
var done int64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
pool.submit(func() {
|
||||||
|
defer wg.Done()
|
||||||
|
atomic.AddInt64(&done, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
pool.stop()
|
||||||
|
|
||||||
|
if got := atomic.LoadInt64(&done); got != 100 {
|
||||||
|
t.Errorf("ran %d jobs, want 100", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrencyIsBounded(t *testing.T) {
|
||||||
|
// The reason the pool exists. Unbounded concurrency would open a database
|
||||||
|
// transaction per message and exhaust the connection pool under a storm,
|
||||||
|
// stalling every one of them at once.
|
||||||
|
const workers = 4
|
||||||
|
pool := newPosPool("test", workers, 64)
|
||||||
|
|
||||||
|
var inFlight, peak int64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
pool.submit(func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
now := atomic.AddInt64(&inFlight, 1)
|
||||||
|
for {
|
||||||
|
was := atomic.LoadInt64(&peak)
|
||||||
|
if now <= was || atomic.CompareAndSwapInt64(&peak, was, now) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
atomic.AddInt64(&inFlight, -1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
pool.stop()
|
||||||
|
|
||||||
|
if got := atomic.LoadInt64(&peak); got > workers {
|
||||||
|
t.Errorf("peak concurrency %d exceeded the %d workers", got, workers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitBlocksRatherThanDroppingWork(t *testing.T) {
|
||||||
|
// A full queue must slow the caller down, not discard a bill. Dropping
|
||||||
|
// would throw away work already accepted from the broker, and the terminal
|
||||||
|
// would only find out at its ack timeout.
|
||||||
|
pool := newPosPool("test", 1, 1)
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
var ran int64
|
||||||
|
|
||||||
|
// Occupy the single worker.
|
||||||
|
pool.submit(func() {
|
||||||
|
<-release
|
||||||
|
atomic.AddInt64(&ran, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fill the queue, then a third submit must block until the worker frees up.
|
||||||
|
pool.submit(func() { atomic.AddInt64(&ran, 1) })
|
||||||
|
|
||||||
|
blocked := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
pool.submit(func() { atomic.AddInt64(&ran, 1) })
|
||||||
|
close(blocked)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-blocked:
|
||||||
|
t.Fatal("submit returned while the queue was full; work would be dropped under load")
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
// Correctly blocked.
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-blocked:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("submit never unblocked after the worker freed up")
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.stop()
|
||||||
|
|
||||||
|
if got := atomic.LoadInt64(&ran); got != 3 {
|
||||||
|
t.Errorf("ran %d jobs, want 3 — none may be lost", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopDrainsAcceptedWork(t *testing.T) {
|
||||||
|
// A bill mid-commit must still get its ack. Without one the terminal holds
|
||||||
|
// it and sends it again on restart — harmless, but avoidable.
|
||||||
|
pool := newPosPool("test", 2, 64)
|
||||||
|
|
||||||
|
var done int64
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
pool.submit(func() {
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
atomic.AddInt64(&done, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.stop()
|
||||||
|
|
||||||
|
if got := atomic.LoadInt64(&done); got != 50 {
|
||||||
|
t.Errorf("only %d of 50 jobs completed before shutdown finished", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitAfterStopStillRunsTheWork(t *testing.T) {
|
||||||
|
// A message that arrived during shutdown has already been taken from the
|
||||||
|
// broker. Discarding it would lose a bill we accepted responsibility for.
|
||||||
|
pool := newPosPool("test", 2, 8)
|
||||||
|
pool.stop()
|
||||||
|
|
||||||
|
var ran int64
|
||||||
|
pool.submit(func() { atomic.AddInt64(&ran, 1) })
|
||||||
|
|
||||||
|
if got := atomic.LoadInt64(&ran); got != 1 {
|
||||||
|
t.Error("work submitted during shutdown was discarded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopIsIdempotent(t *testing.T) {
|
||||||
|
// Close() may be reached twice on a shutdown path; a second close of the
|
||||||
|
// jobs channel would panic and take the process down mid-drain.
|
||||||
|
pool := newPosPool("test", 2, 8)
|
||||||
|
pool.stop()
|
||||||
|
pool.stop()
|
||||||
|
pool.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolSizeFallsBackAndClamps(t *testing.T) {
|
||||||
|
t.Setenv("POS_TEST_WORKERS", "")
|
||||||
|
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
|
||||||
|
t.Errorf("unset = %d, want the fallback 8", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("POS_TEST_WORKERS", "not a number")
|
||||||
|
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
|
||||||
|
t.Errorf("garbage = %d, want the fallback 8", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("POS_TEST_WORKERS", "0")
|
||||||
|
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
|
||||||
|
t.Errorf("zero = %d, want the fallback 8", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("POS_TEST_WORKERS", "-4")
|
||||||
|
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
|
||||||
|
t.Errorf("negative = %d, want the fallback 8", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("POS_TEST_WORKERS", "24")
|
||||||
|
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 24 {
|
||||||
|
t.Errorf("explicit = %d, want 24", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamped: more workers than the database can serve just moves the queue
|
||||||
|
// inside the driver, where there is no backpressure to feel.
|
||||||
|
t.Setenv("POS_TEST_WORKERS", "100000")
|
||||||
|
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 128 {
|
||||||
|
t.Errorf("absurd = %d, want the 128 clamp", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWrappedHandlerCopiesThePayload(t *testing.T) {
|
||||||
|
// paho reuses its buffer once a handler returns, and with a pool the work
|
||||||
|
// now happens *after* that. Without a copy a queued bill would be read as
|
||||||
|
// whatever message happened to arrive next — silently, and as valid JSON
|
||||||
|
// often enough to commit the wrong sale.
|
||||||
|
pool := newPosPool("test", 1, 4)
|
||||||
|
|
||||||
|
seen := make(chan string, 1)
|
||||||
|
wrapped := wrapHandler(pool, func(_ mqtt.Client, msg mqtt.Message) {
|
||||||
|
seen <- string(msg.Payload())
|
||||||
|
})
|
||||||
|
|
||||||
|
// A buffer paho would reuse.
|
||||||
|
buffer := []byte(`{"batch_id":"original"}`)
|
||||||
|
wrapped(nil, fakeMessage{topic: "nearle/pos/12/T4A9/order", payload: buffer})
|
||||||
|
|
||||||
|
// Overwrite it the instant the handler returns, exactly as paho would.
|
||||||
|
for i := range buffer {
|
||||||
|
buffer[i] = 'X'
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-seen:
|
||||||
|
if got != `{"batch_id":"original"}` {
|
||||||
|
t.Errorf("handler saw %q — the payload was not copied before queueing", got)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("the wrapped handler never ran")
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWrappedHandlerKeepsTheTopic(t *testing.T) {
|
||||||
|
// Store and terminal are read from the topic, never the body. Losing it in
|
||||||
|
// the hand-off would leave the ack with nowhere to go.
|
||||||
|
pool := newPosPool("test", 1, 4)
|
||||||
|
|
||||||
|
seen := make(chan string, 1)
|
||||||
|
wrapped := wrapHandler(pool, func(_ mqtt.Client, msg mqtt.Message) {
|
||||||
|
seen <- msg.Topic()
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapped(nil, fakeMessage{topic: "nearle/pos/1135/T4A9/order", payload: []byte("{}")})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-seen:
|
||||||
|
if got != "nearle/pos/1135/T4A9/order" {
|
||||||
|
t.Errorf("topic = %q, want nearle/pos/1135/T4A9/order", got)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("the wrapped handler never ran")
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.stop()
|
||||||
|
}
|
||||||
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,3 +136,74 @@ type PosOrderItems struct {
|
|||||||
func (PosOrderItems) TableName() string {
|
func (PosOrderItems) TableName() string {
|
||||||
return "pos_order_items"
|
return "pos_order_items"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PosSalesFilter scopes a query over counter sales.
|
||||||
|
//
|
||||||
|
// Locationid is required and is the authorisation boundary — every read is
|
||||||
|
// scoped to one outlet, so a caller cannot page through another shop's takings
|
||||||
|
// by omitting a parameter.
|
||||||
|
type PosSalesFilter struct {
|
||||||
|
Locationid int
|
||||||
|
Fromdate string // YYYY-MM-DD, matched against businessdate
|
||||||
|
Todate string
|
||||||
|
Terminalid string
|
||||||
|
Cashiername string
|
||||||
|
Paymentmode string
|
||||||
|
Pageno int
|
||||||
|
Pagesize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosSalesPage is one page of bills, with the total so a caller can paginate
|
||||||
|
// without a second request.
|
||||||
|
type PosSalesPage struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Pageno int `json:"pageno"`
|
||||||
|
Pagesize int `json:"pagesize"`
|
||||||
|
Bills []PosOrders `json:"bills"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosSalesSummary totals a range of counter sales.
|
||||||
|
//
|
||||||
|
// Deliberately separate from the bill list: a shop settling a till wants the
|
||||||
|
// figures, not five hundred rows, and computing them client-side would mean
|
||||||
|
// fetching every page first.
|
||||||
|
type PosSalesSummary struct {
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Fromdate string `json:"fromdate"`
|
||||||
|
Todate string `json:"todate"`
|
||||||
|
Billcount int `json:"billcount"`
|
||||||
|
Itemcount int `json:"itemcount"`
|
||||||
|
Grosssales float64 `json:"grosssales"`
|
||||||
|
Taxcollected float64 `json:"taxcollected"`
|
||||||
|
Discount float64 `json:"discountgiven"`
|
||||||
|
Roundoff float64 `json:"roundoff"`
|
||||||
|
Averagebill float64 `json:"averagebill"`
|
||||||
|
|
||||||
|
// What a cashier reconciles the drawer against.
|
||||||
|
Bypaymentmode []PosPaymentTotal `json:"bypaymentmode"`
|
||||||
|
|
||||||
|
// One row per trading day, for a chart.
|
||||||
|
Byday []PosDayTotal `json:"byday"`
|
||||||
|
|
||||||
|
// Which tills contributed, so an outlet with several counters can see them
|
||||||
|
// apart without a second query.
|
||||||
|
Byterminal []PosTerminalTotal `json:"byterminal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PosPaymentTotal struct {
|
||||||
|
Paymentmode string `json:"paymentmode"`
|
||||||
|
Billcount int `json:"billcount"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PosDayTotal struct {
|
||||||
|
Businessdate string `json:"businessdate"`
|
||||||
|
Billcount int `json:"billcount"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PosTerminalTotal struct {
|
||||||
|
Terminalid string `json:"terminalid"`
|
||||||
|
Billcount int `json:"billcount"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,8 +77,16 @@ type Products struct {
|
|||||||
Productcombo int `json:"productcombo" gorm:"default:0"`
|
Productcombo int `json:"productcombo" gorm:"default:0"`
|
||||||
Variants int `json:"variants" gorm:"default:0"`
|
Variants int `json:"variants" gorm:"default:0"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
Retailprice float64 `json:"retailprice,omitempty"`
|
// Price is the EFFECTIVE selling price at the location a query was scoped
|
||||||
Diffprice float64 `json:"diffprice,omitempty"`
|
// to: productlocations.price when the store has set one, otherwise the
|
||||||
|
// master Retailprice below. Read-only — it is computed by the query, never
|
||||||
|
// written through this struct. Location-scoped endpoints must expose it, or
|
||||||
|
// a price the admin sets per store can never reach the customer app: they
|
||||||
|
// returned only Retailprice, which the admin catalogue never writes.
|
||||||
|
// Same meaning as Locationproducts.Price, so both product feeds agree.
|
||||||
|
Price float64 `json:"price" gorm:"->"`
|
||||||
|
Retailprice float64 `json:"retailprice,omitempty"`
|
||||||
|
Diffprice float64 `json:"diffprice,omitempty"`
|
||||||
Diffpercent float64 `json:"diffpercent,omitempty"`
|
Diffpercent float64 `json:"diffpercent,omitempty"`
|
||||||
Othercost float64 `json:"othercost,omitempty"`
|
Othercost float64 `json:"othercost,omitempty"`
|
||||||
Approve int `json:"approve"`
|
Approve int `json:"approve"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -1383,6 +1383,110 @@ func (r *orderRepository) reloadOrder(orderHeaderID int) (models.Orders, error)
|
|||||||
// use tx again. On success tx is left open and uncommitted, so the caller can
|
// use tx again. On success tx is left open and uncommitted, so the caller can
|
||||||
// include its own work — a duplicate-bill guard, an advisory lock — in the same
|
// include its own work — a duplicate-bill guard, an advisory lock — in the same
|
||||||
// transaction as the order that work protects.
|
// transaction as the order that work protects.
|
||||||
|
// priceOrderLines fills in any line the client sent without a price, using the
|
||||||
|
// merchant's own catalogue, and brings the header totals in line with the
|
||||||
|
// result. It mutates data in place and is a no-op for an order that already
|
||||||
|
// arrived fully priced.
|
||||||
|
//
|
||||||
|
// The arithmetic deliberately matches the offline-sales import exactly — gross,
|
||||||
|
// minus discount, with tax extracted from the resulting landing amount because
|
||||||
|
// shelf prices here are MRP (tax already inside). One convention for both
|
||||||
|
// channels, so the same basket rings up the same either way.
|
||||||
|
func (r *orderRepository) priceOrderLines(tx *gorm.DB, data *models.Orders, defaultLocID int) error {
|
||||||
|
if len(data.Items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// One catalogue read per outlet, not per line. Items usually share an
|
||||||
|
// outlet, but a line may name its own.
|
||||||
|
catalogues := make(map[int]map[int]offlineProduct)
|
||||||
|
catalogueFor := func(locationID int) (map[int]offlineProduct, error) {
|
||||||
|
if c, ok := catalogues[locationID]; ok {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
c, err := loadCatalogueProducts(tx, data.Tenantid, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
catalogues[locationID] = c
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lineTotal, taxTotal float64
|
||||||
|
|
||||||
|
for i := range data.Items {
|
||||||
|
item := &data.Items[i]
|
||||||
|
|
||||||
|
itemLocID := item.Locationid
|
||||||
|
if itemLocID == 0 {
|
||||||
|
itemLocID = defaultLocID
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Price <= 0 {
|
||||||
|
catalogue, err := catalogueFor(itemLocID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A miss can't normally happen — the stock check above already
|
||||||
|
// proved the product is stocked here. If it somehow does, leave the
|
||||||
|
// line as the client sent it rather than refusing the order: a
|
||||||
|
// pricing lookup is not a reason to block a customer's checkout.
|
||||||
|
if product, ok := catalogue[item.Productid]; ok {
|
||||||
|
item.Price = product.Price
|
||||||
|
if item.Taxpercentage <= 0 {
|
||||||
|
item.Taxpercentage = product.Taxpercent
|
||||||
|
}
|
||||||
|
if item.Productname == "" {
|
||||||
|
item.Productname = product.Productname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gross := item.Price * item.Orderqty
|
||||||
|
discount := item.Discountamount
|
||||||
|
if discount < 0 {
|
||||||
|
discount = 0
|
||||||
|
}
|
||||||
|
if discount > gross {
|
||||||
|
discount = gross
|
||||||
|
}
|
||||||
|
landing := gross - discount
|
||||||
|
|
||||||
|
// Only derive what the client didn't state, so a client that does its
|
||||||
|
// own (possibly promotional) maths keeps its figures.
|
||||||
|
if item.Productsumprice <= 0 {
|
||||||
|
item.Productsumprice = gross
|
||||||
|
}
|
||||||
|
if item.Landingamount <= 0 {
|
||||||
|
item.Landingamount = landing
|
||||||
|
}
|
||||||
|
if item.Taxamount <= 0 && item.Taxpercentage > 0 {
|
||||||
|
item.Taxamount = landing - (landing / (1 + item.Taxpercentage/100))
|
||||||
|
}
|
||||||
|
|
||||||
|
lineTotal += item.Landingamount
|
||||||
|
taxTotal += item.Taxamount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header totals are only derived when the client left them empty; an order
|
||||||
|
// that states its own total (delivery charges, promotions applied basket-
|
||||||
|
// wide) keeps it.
|
||||||
|
if data.Orderamount <= 0 {
|
||||||
|
data.Orderamount = float32(lineTotal)
|
||||||
|
}
|
||||||
|
if data.Ordervalue <= 0 {
|
||||||
|
data.Ordervalue = float32(lineTotal)
|
||||||
|
}
|
||||||
|
if data.Taxamount <= 0 {
|
||||||
|
data.Taxamount = float32(taxTotal)
|
||||||
|
}
|
||||||
|
if data.Itemcount <= 0 {
|
||||||
|
data.Itemcount = len(data.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models.Orders, error) {
|
func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models.Orders, error) {
|
||||||
locID := data.Locationid
|
locID := data.Locationid
|
||||||
if locID == 0 {
|
if locID == 0 {
|
||||||
@@ -1424,6 +1528,22 @@ func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models
|
|||||||
return models.Orders{}, err
|
return models.Orders{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🛠️ Step 1b: Price the lines the client left unpriced.
|
||||||
|
//
|
||||||
|
// Line prices arrive from the client, and a client that sends none books the
|
||||||
|
// order at zero — which is exactly what happened to every catalogue-imported
|
||||||
|
// product, whose per-store price was never set: real orders were written
|
||||||
|
// with price 0 and orderamount 0, so a delivered sale recorded no revenue.
|
||||||
|
//
|
||||||
|
// Only lines the client left at or below zero are filled. A line that came
|
||||||
|
// with a price keeps it, because variants, addons and promotions legitimately
|
||||||
|
// charge something other than the shelf price and this is not the place to
|
||||||
|
// second-guess them.
|
||||||
|
if err := r.priceOrderLines(tx, &data, locID); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return models.Orders{}, err
|
||||||
|
}
|
||||||
|
|
||||||
// 🛠️ Step 2: Create Order Header
|
// 🛠️ Step 2: Create Order Header
|
||||||
// Claimed inside tx so the row lock on the counter holds until commit:
|
// Claimed inside tx so the row lock on the counter holds until commit:
|
||||||
// concurrent orders queue for it instead of reading the same number, and a
|
// concurrent orders queue for it instead of reading the same number, and a
|
||||||
@@ -1632,8 +1752,21 @@ type offlineProduct struct {
|
|||||||
// way the line is refused — so a hand-edited productid cannot reach into a
|
// way the line is refused — so a hand-edited productid cannot reach into a
|
||||||
// catalogue the uploader has no claim on.
|
// catalogue the uploader has no claim on.
|
||||||
func (r *orderRepository) loadOfflineProducts(tenantID, locationID int) (map[int]offlineProduct, error) {
|
func (r *orderRepository) loadOfflineProducts(tenantID, locationID int) (map[int]offlineProduct, error) {
|
||||||
|
return loadCatalogueProducts(r.db, tenantID, locationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCatalogueProducts is the shared price/tax lookup: the merchant's own
|
||||||
|
// selling price for every product stocked at one outlet, preferring the
|
||||||
|
// per-store productlocations.price and falling back to the master
|
||||||
|
// products.retailprice. Both order paths price from this one query so an online
|
||||||
|
// order and a counter sale can never disagree about what a product costs.
|
||||||
|
//
|
||||||
|
// Takes its handle so a caller inside a transaction reads through that
|
||||||
|
// transaction — createOrderTx has already locked these product rows, and
|
||||||
|
// reading around the lock would defeat the point.
|
||||||
|
func loadCatalogueProducts(db *gorm.DB, tenantID, locationID int) (map[int]offlineProduct, error) {
|
||||||
rows := make([]offlineProduct, 0)
|
rows := make([]offlineProduct, 0)
|
||||||
err := r.db.Raw(`
|
err := db.Raw(`
|
||||||
SELECT a.productid,
|
SELECT a.productid,
|
||||||
COALESCE(a.productname, '') AS productname,
|
COALESCE(a.productname, '') AS productname,
|
||||||
COALESCE(a.productunit, '') AS productunit,
|
COALESCE(a.productunit, '') AS productunit,
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -37,6 +37,28 @@ type PosRepository interface {
|
|||||||
IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error)
|
IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error)
|
||||||
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
|
||||||
|
// unreachable from every screen in the product.
|
||||||
|
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
|
||||||
|
SaleDetail(locationID int, reference string) (*models.PosOrders, error)
|
||||||
|
SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type posRepository struct {
|
type posRepository struct {
|
||||||
@@ -106,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
|
||||||
}
|
}
|
||||||
@@ -126,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 {
|
||||||
@@ -296,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),
|
||||||
@@ -366,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 ""
|
||||||
@@ -385,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 == "" {
|
||||||
@@ -488,12 +545,67 @@ func (r *posRepository) upsertPosCustomer(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catalogue answers a terminal's morning pull.
|
// posRevisionLayout is the timestamp inside a catalogue revision.
|
||||||
//
|
//
|
||||||
// Always a full snapshot today, and it says so. The terminal withdraws every
|
// The revision is the terminal's memory of when it last pulled: it stores what
|
||||||
// product a snapshot omits, so answering a change set with is_delta false would
|
// we send and hands it back on the next request, and the time encoded in it is
|
||||||
// empty the shelf — declaring false here is only safe because this really does
|
// the cutoff for what has changed since. Colons are avoided so the whole string
|
||||||
// return everything stocked at the outlet.
|
// stays safe in a URL query without escaping.
|
||||||
|
const posRevisionLayout = "20060102T150405Z"
|
||||||
|
|
||||||
|
// posRevisionFor mints the revision a terminal will send back to us.
|
||||||
|
func posRevisionFor(locationID int, at time.Time) string {
|
||||||
|
return fmt.Sprintf("loc%d-%s", locationID, at.UTC().Format(posRevisionLayout))
|
||||||
|
}
|
||||||
|
|
||||||
|
// posRevisionCutoff reads the timestamp back out of a revision.
|
||||||
|
//
|
||||||
|
// Returns the zero time when the revision is missing, malformed, or belongs to
|
||||||
|
// a different outlet — and a zero cutoff means "send everything". Falling back
|
||||||
|
// to a full snapshot is the only safe direction: answering an unreadable
|
||||||
|
// revision with a *delta* would leave the terminal quietly missing every change
|
||||||
|
// it had not already seen, with nothing to indicate it.
|
||||||
|
func posRevisionCutoff(locationID int, revision string) time.Time {
|
||||||
|
revision = strings.TrimSpace(revision)
|
||||||
|
prefix := fmt.Sprintf("loc%d-", locationID)
|
||||||
|
if !strings.HasPrefix(revision, prefix) {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
at, err := time.Parse(posRevisionLayout, strings.TrimPrefix(revision, prefix))
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalogue answers a terminal's pull, as a snapshot or as a change set.
|
||||||
|
//
|
||||||
|
// ### The rule this function exists to keep
|
||||||
|
//
|
||||||
|
// A response with `is_delta: false` is treated as a full snapshot, and the
|
||||||
|
// terminal **withdraws every product the response does not mention**. So a
|
||||||
|
// filtered result labelled `false` empties the shop's shelf.
|
||||||
|
//
|
||||||
|
// The two are therefore decided together, from one value: a zero cutoff means
|
||||||
|
// no filter and `is_delta: false`; a non-zero cutoff means filtered and
|
||||||
|
// `is_delta: true`. There is no path through this function that filters without
|
||||||
|
// also setting the flag.
|
||||||
|
//
|
||||||
|
// ### What counts as a change
|
||||||
|
//
|
||||||
|
// A product is included when any of three things moved since the cutoff: the
|
||||||
|
// product row itself (name, tax, brand), its row at this location (price,
|
||||||
|
// availability), or its stock ledger. Stock is included because a shop's count
|
||||||
|
// drifts from the till's on every sale rung elsewhere, and a delta that omitted
|
||||||
|
// it would let that drift persist until someone forced a full pull.
|
||||||
|
//
|
||||||
|
// ### What a delta cannot do
|
||||||
|
//
|
||||||
|
// A product *deleted* from productlocations leaves no tombstone, so a change set
|
||||||
|
// cannot know to withdraw it. Only a full snapshot collects those. A terminal
|
||||||
|
// should pull without a revision periodically — the morning import is the
|
||||||
|
// natural moment — and this is why.
|
||||||
func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
|
func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
|
||||||
ctx, err := r.resolvePosStore(storeID)
|
ctx, err := r.resolvePosStore(storeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -507,6 +619,11 @@ func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*m
|
|||||||
page = 0
|
page = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The single decision. Everything downstream reads this rather than
|
||||||
|
// re-deriving it, so the filter and the flag cannot disagree.
|
||||||
|
cutoff := posRevisionCutoff(ctx.Locationid, since)
|
||||||
|
isDelta := !cutoff.IsZero()
|
||||||
|
|
||||||
type row struct {
|
type row struct {
|
||||||
Productid int
|
Productid int
|
||||||
Productname string
|
Productname string
|
||||||
@@ -521,8 +638,25 @@ func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*m
|
|||||||
Status string
|
Status string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A product counts as changed if the product row, its row at this location,
|
||||||
|
// or its stock ledger moved. Written as one predicate so a delta cannot
|
||||||
|
// miss a price change simply because the product row was untouched.
|
||||||
|
changed := ""
|
||||||
|
params := []interface{}{ctx.Tenantid, ctx.Locationid}
|
||||||
|
if isDelta {
|
||||||
|
changed = `AND (
|
||||||
|
a.updated >= ?
|
||||||
|
OR b.updated >= ?
|
||||||
|
OR EXISTS (SELECT 1 FROM productstocks s2
|
||||||
|
WHERE s2.productid = a.productid AND s2.tenantid = a.tenantid
|
||||||
|
AND s2.locationid = b.locationid
|
||||||
|
AND (s2.stockdate >= ? OR s2.updated >= ?))
|
||||||
|
)`
|
||||||
|
params = append(params, cutoff, cutoff, cutoff, cutoff)
|
||||||
|
}
|
||||||
|
|
||||||
rows := make([]row, 0)
|
rows := make([]row, 0)
|
||||||
err = r.db.Raw(`
|
query := fmt.Sprintf(`
|
||||||
SELECT a.productid,
|
SELECT a.productid,
|
||||||
COALESCE(a.productname, '') AS productname,
|
COALESCE(a.productname, '') AS productname,
|
||||||
COALESCE(a.productsku, '') AS productsku,
|
COALESCE(a.productsku, '') AS productsku,
|
||||||
@@ -542,12 +676,15 @@ func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*m
|
|||||||
FROM products a
|
FROM products a
|
||||||
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
|
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
|
||||||
LEFT JOIN productcategories c ON a.categoryid = c.categoryid
|
LEFT JOIN productcategories c ON a.categoryid = c.categoryid
|
||||||
WHERE a.tenantid = ? AND b.locationid = ?
|
WHERE a.tenantid = ? AND b.locationid = ? AND a.productid > 0 %s
|
||||||
ORDER BY a.productid
|
ORDER BY a.productid
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`, changed)
|
||||||
ctx.Tenantid, ctx.Locationid, pageSize+1, page*pageSize,
|
|
||||||
).Scan(&rows).Error
|
// One row past the page, purely so has_more can be answered without a
|
||||||
if err != nil {
|
// second count query.
|
||||||
|
params = append(params, pageSize+1, page*pageSize)
|
||||||
|
|
||||||
|
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,14 +697,12 @@ func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*m
|
|||||||
|
|
||||||
products := make([]models.PosCatalogueProduct, 0, len(rows))
|
products := make([]models.PosCatalogueProduct, 0, len(rows))
|
||||||
for _, p := range rows {
|
for _, p := range rows {
|
||||||
// A productid of zero is bad data, not a product — live data has at
|
// Rows with productid <= 0 are excluded in SQL rather than here. Live
|
||||||
// least one, almost certainly an insert that never got a sequence
|
// data has at least one — almost certainly an insert that never got a
|
||||||
// value. Sending it would put a row on the till that can never be
|
// sequence value — and it can never be billed, because the ingest
|
||||||
// billed, because the ingest refuses any line whose id is not positive.
|
// refuses any line whose id is not positive. Filtering it in the query
|
||||||
if p.Productid <= 0 {
|
// also keeps pagination exact: skipped after the LIMIT, it would eat a
|
||||||
continue
|
// slot and hand back a short page.
|
||||||
}
|
|
||||||
|
|
||||||
mrp := p.Retailprice
|
mrp := p.Retailprice
|
||||||
if mrp <= p.Price {
|
if mrp <= p.Price {
|
||||||
mrp = 0
|
mrp = 0
|
||||||
@@ -606,15 +741,39 @@ func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*m
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The revision only advances on the final page.
|
||||||
|
//
|
||||||
|
// A terminal that gives up half way through a paginated pull — a dropped
|
||||||
|
// connection, a till switched off — must not be left holding a revision
|
||||||
|
// that claims it has seen pages it never received. Every one of those
|
||||||
|
// products would then be excluded from the next delta and stay stale
|
||||||
|
// indefinitely, with nothing anywhere to indicate it.
|
||||||
|
//
|
||||||
|
// So mid-pull we echo back whatever the terminal already had: unchanged if
|
||||||
|
// it sent one, empty if it did not, and empty means the next pull is a full
|
||||||
|
// snapshot. Both are recoverable; a prematurely advanced revision is not.
|
||||||
|
//
|
||||||
|
// The stamp is taken a second in the past. A product written during the
|
||||||
|
// same second this query ran could otherwise land on the wrong side of the
|
||||||
|
// next cutoff and be skipped for good — overlapping by a second costs one
|
||||||
|
// redundant row and cannot lose one.
|
||||||
|
revision := strings.TrimSpace(since)
|
||||||
|
if !hasMore {
|
||||||
|
revision = posRevisionFor(ctx.Locationid, time.Now().Add(-time.Second))
|
||||||
|
}
|
||||||
|
|
||||||
return &models.PosCatalogueResponse{
|
return &models.PosCatalogueResponse{
|
||||||
// A revision the terminal stores and sends back on its next pull. Tied
|
Revision: revision,
|
||||||
// to the outlet and the moment, so a shop that has pulled today can be
|
// Decided with the filter, never separately. False here would tell the
|
||||||
// told it is already current.
|
// terminal to withdraw every product this response omits.
|
||||||
Revision: fmt.Sprintf("loc%d-%s", ctx.Locationid, time.Now().UTC().Format("20060102T150405")),
|
Isdelta: isDelta,
|
||||||
Isdelta: false,
|
Hasmore: hasMore,
|
||||||
Hasmore: hasMore,
|
Products: products,
|
||||||
Products: products,
|
Customers: make([]models.PosCatalogueCustomer, 0),
|
||||||
Customers: make([]models.PosCatalogueCustomer, 0),
|
// A product deleted from productlocations leaves no tombstone, so a
|
||||||
|
// change set cannot know to withdraw it. Only a full snapshot collects
|
||||||
|
// those, which is why a terminal should pull without a revision
|
||||||
|
// periodically.
|
||||||
Retiredids: make([]string, 0),
|
Retiredids: make([]string, 0),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package repositories
|
package repositories
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// The terminal holds a unique index on barcode, so this rule decides whether a
|
// The terminal holds a unique index on barcode, so this rule decides whether a
|
||||||
// catalogue import succeeds at all. Measured against live data when it was
|
// catalogue import succeeds at all. Measured against live data when it was
|
||||||
@@ -95,3 +98,156 @@ func TestLegacyOrderQtyIsUnchanged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A catalogue revision is the terminal's memory of when it last pulled. If it
|
||||||
|
// does not survive a round trip, every pull silently becomes a full snapshot —
|
||||||
|
// or worse, a filtered result gets labelled as one and the shop's shelf empties.
|
||||||
|
func TestPosRevisionRoundTrips(t *testing.T) {
|
||||||
|
at := time.Date(2026, 8, 3, 12, 30, 45, 0, time.UTC)
|
||||||
|
|
||||||
|
revision := posRevisionFor(1135, at)
|
||||||
|
if revision != "loc1135-20260803T123045Z" {
|
||||||
|
t.Fatalf("revision = %q, want loc1135-20260803T123045Z", revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := posRevisionCutoff(1135, revision)
|
||||||
|
if !got.Equal(at) {
|
||||||
|
t.Errorf("cutoff = %v, want %v", got, at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnusableRevisionFallsBackToAFullSnapshot(t *testing.T) {
|
||||||
|
// A zero cutoff means "send everything", and the caller turns that into
|
||||||
|
// is_delta:false. Falling back the other way — answering an unreadable
|
||||||
|
// revision with a change set — would leave a terminal permanently missing
|
||||||
|
// every change it had not already seen, with nothing to show for it.
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
location int
|
||||||
|
revision string
|
||||||
|
}{
|
||||||
|
{"empty", 1135, ""},
|
||||||
|
{"whitespace", 1135, " "},
|
||||||
|
{"no prefix", 1135, "20260803T123045Z"},
|
||||||
|
{"malformed timestamp", 1135, "loc1135-not-a-time"},
|
||||||
|
{"truncated timestamp", 1135, "loc1135-20260803"},
|
||||||
|
{"another outlet's revision", 1135, "loc1097-20260803T123045Z"},
|
||||||
|
{"prefix collision", 113, "loc1135-20260803T123045Z"},
|
||||||
|
{"garbage", 1135, "../../etc/passwd"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := posRevisionCutoff(c.location, c.revision); !got.IsZero() {
|
||||||
|
t.Errorf("cutoff = %v, want zero (full snapshot) for %q", got, c.revision)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnOutletCannotReplayAnotherOutletsRevision(t *testing.T) {
|
||||||
|
// loc1135 and loc113 share a textual prefix. Matching loosely would let one
|
||||||
|
// shop's cutoff silently scope another shop's delta.
|
||||||
|
at := time.Date(2026, 8, 3, 12, 30, 45, 0, time.UTC)
|
||||||
|
revision := posRevisionFor(1135, at)
|
||||||
|
|
||||||
|
if got := posRevisionCutoff(1135, revision); got.IsZero() {
|
||||||
|
t.Error("the issuing outlet could not read back its own revision")
|
||||||
|
}
|
||||||
|
for _, other := range []int{113, 11350, 1097, 1} {
|
||||||
|
if got := posRevisionCutoff(other, revision); !got.IsZero() {
|
||||||
|
t.Errorf("outlet %d accepted outlet 1135's revision (cutoff %v)", other, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
223
repositories/posSalesRepository.go
Normal file
223
repositories/posSalesRepository.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reading counter sales back out.
|
||||||
|
//
|
||||||
|
// The ingest side of this package only ever writes. Without these, a bill that
|
||||||
|
// reached pos_orders was invisible to every screen in the product — the data
|
||||||
|
// was safe and unreachable, which is its own kind of lost.
|
||||||
|
//
|
||||||
|
// Every query is scoped to one locationid. That is the authorisation boundary:
|
||||||
|
// a caller who omits it gets an error rather than a page through somebody
|
||||||
|
// else's takings.
|
||||||
|
|
||||||
|
// posSalesWhere builds the shared filter, so the list, the detail and the
|
||||||
|
// summary can never disagree about what "this outlet in this range" means.
|
||||||
|
func posSalesWhere(f models.PosSalesFilter) (string, []interface{}) {
|
||||||
|
where := "locationid = ?"
|
||||||
|
params := []interface{}{f.Locationid}
|
||||||
|
|
||||||
|
// Matched on businessdate — the day the sale was rung, not the day it
|
||||||
|
// reached us. A till that was offline overnight uploads yesterday's bills
|
||||||
|
// this morning and they belong to yesterday.
|
||||||
|
if f.Fromdate != "" && f.Todate != "" {
|
||||||
|
where += " AND businessdate BETWEEN ? AND ?"
|
||||||
|
params = append(params, f.Fromdate, f.Todate)
|
||||||
|
} else if f.Fromdate != "" {
|
||||||
|
where += " AND businessdate >= ?"
|
||||||
|
params = append(params, f.Fromdate)
|
||||||
|
} else if f.Todate != "" {
|
||||||
|
where += " AND businessdate <= ?"
|
||||||
|
params = append(params, f.Todate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if t := strings.TrimSpace(f.Terminalid); t != "" {
|
||||||
|
where += " AND terminalid = ?"
|
||||||
|
params = append(params, t)
|
||||||
|
}
|
||||||
|
if c := strings.TrimSpace(f.Cashiername); c != "" {
|
||||||
|
where += " AND cashiername = ?"
|
||||||
|
params = append(params, c)
|
||||||
|
}
|
||||||
|
if p := strings.TrimSpace(f.Paymentmode); p != "" {
|
||||||
|
where += " AND LOWER(paymentmode) = ?"
|
||||||
|
params = append(params, strings.ToLower(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
return where, params
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sales returns a page of bills, newest first, with the total count.
|
||||||
|
//
|
||||||
|
// Line items are deliberately not included: a page of fifty bills would drag
|
||||||
|
// several hundred rows behind it, and a list screen shows none of them. Use
|
||||||
|
// SaleDetail for one bill.
|
||||||
|
func (r *posRepository) Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) {
|
||||||
|
if f.Locationid <= 0 {
|
||||||
|
return nil, fmt.Errorf("locationid is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.Pagesize <= 0 || f.Pagesize > 500 {
|
||||||
|
f.Pagesize = 50
|
||||||
|
}
|
||||||
|
if f.Pageno < 0 {
|
||||||
|
f.Pageno = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
where, params := posSalesWhere(f)
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Raw(
|
||||||
|
fmt.Sprintf(`SELECT COUNT(*) FROM pos_orders WHERE %s`, where),
|
||||||
|
params...,
|
||||||
|
).Scan(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bills := make([]models.PosOrders, 0)
|
||||||
|
// Ordered by billedat rather than by id: a batch uploaded after an outage
|
||||||
|
// arrives out of order, and a list sorted by arrival would interleave
|
||||||
|
// yesterday's bills through today's.
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
`SELECT * FROM pos_orders WHERE %s
|
||||||
|
ORDER BY billedat DESC, posorderid DESC
|
||||||
|
LIMIT ? OFFSET ?`, where)
|
||||||
|
|
||||||
|
if err := r.db.Raw(query,
|
||||||
|
append(params, f.Pagesize, f.Pageno*f.Pagesize)...,
|
||||||
|
).Scan(&bills).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.PosSalesPage{
|
||||||
|
Total: total,
|
||||||
|
Pageno: f.Pageno,
|
||||||
|
Pagesize: f.Pagesize,
|
||||||
|
Bills: bills,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaleDetail returns one bill with its lines.
|
||||||
|
//
|
||||||
|
// Accepts either the terminal's own order UUID or this backend's posorderid,
|
||||||
|
// because a support call starts from whichever the caller happens to be looking
|
||||||
|
// at — a receipt carries the invoice number, a log carries the UUID.
|
||||||
|
func (r *posRepository) SaleDetail(locationID int, reference string) (*models.PosOrders, error) {
|
||||||
|
if locationID <= 0 {
|
||||||
|
return nil, fmt.Errorf("locationid is required")
|
||||||
|
}
|
||||||
|
reference = strings.TrimSpace(reference)
|
||||||
|
if reference == "" {
|
||||||
|
return nil, fmt.Errorf("an order id, invoice number or posorderid is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var bill models.PosOrders
|
||||||
|
err := r.db.Raw(`
|
||||||
|
SELECT * FROM pos_orders
|
||||||
|
WHERE locationid = ?
|
||||||
|
AND (terminalorderid = ? OR invoicenumber = ?
|
||||||
|
OR CAST(posorderid AS TEXT) = ?)
|
||||||
|
LIMIT 1`,
|
||||||
|
locationID, reference, reference, reference,
|
||||||
|
).Scan(&bill).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if bill.Posorderid == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]models.PosOrderItems, 0)
|
||||||
|
if err := r.db.Raw(
|
||||||
|
`SELECT * FROM pos_order_items WHERE posorderid = ? ORDER BY posorderitemid`,
|
||||||
|
bill.Posorderid,
|
||||||
|
).Scan(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bill.Items = items
|
||||||
|
|
||||||
|
return &bill, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SalesSummary totals a range, broken out the three ways somebody actually
|
||||||
|
// asks for: by tender, by day, and by till.
|
||||||
|
func (r *posRepository) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) {
|
||||||
|
if f.Locationid <= 0 {
|
||||||
|
return nil, fmt.Errorf("locationid is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
where, params := posSalesWhere(f)
|
||||||
|
|
||||||
|
summary := &models.PosSalesSummary{
|
||||||
|
Locationid: f.Locationid,
|
||||||
|
Fromdate: f.Fromdate,
|
||||||
|
Todate: f.Todate,
|
||||||
|
Bypaymentmode: make([]models.PosPaymentTotal, 0),
|
||||||
|
Byday: make([]models.PosDayTotal, 0),
|
||||||
|
Byterminal: make([]models.PosTerminalTotal, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
var head struct {
|
||||||
|
Billcount int
|
||||||
|
Itemcount int
|
||||||
|
Grosssales float64
|
||||||
|
Taxcollected float64
|
||||||
|
Discount float64
|
||||||
|
Roundoff float64
|
||||||
|
}
|
||||||
|
if err := r.db.Raw(fmt.Sprintf(`
|
||||||
|
SELECT COUNT(*) AS billcount,
|
||||||
|
COALESCE(SUM(itemcount), 0) AS itemcount,
|
||||||
|
COALESCE(SUM(total), 0) AS grosssales,
|
||||||
|
COALESCE(SUM(taxamount), 0) AS taxcollected,
|
||||||
|
COALESCE(SUM(discount), 0) AS discount,
|
||||||
|
COALESCE(SUM(roundoff), 0) AS roundoff
|
||||||
|
FROM pos_orders WHERE %s`, where), params...).Scan(&head).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Billcount = head.Billcount
|
||||||
|
summary.Itemcount = head.Itemcount
|
||||||
|
summary.Grosssales = head.Grosssales
|
||||||
|
summary.Taxcollected = head.Taxcollected
|
||||||
|
summary.Discount = head.Discount
|
||||||
|
summary.Roundoff = head.Roundoff
|
||||||
|
if head.Billcount > 0 {
|
||||||
|
summary.Averagebill = head.Grosssales / float64(head.Billcount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.db.Raw(fmt.Sprintf(`
|
||||||
|
SELECT COALESCE(paymentmode,'') AS paymentmode,
|
||||||
|
COUNT(*) AS billcount, COALESCE(SUM(total),0) AS amount
|
||||||
|
FROM pos_orders WHERE %s
|
||||||
|
GROUP BY paymentmode ORDER BY amount DESC`, where),
|
||||||
|
params...).Scan(&summary.Bypaymentmode).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.db.Raw(fmt.Sprintf(`
|
||||||
|
SELECT businessdate, COUNT(*) AS billcount,
|
||||||
|
COALESCE(SUM(total),0) AS amount
|
||||||
|
FROM pos_orders WHERE %s
|
||||||
|
GROUP BY businessdate ORDER BY businessdate`, where),
|
||||||
|
params...).Scan(&summary.Byday).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.db.Raw(fmt.Sprintf(`
|
||||||
|
SELECT COALESCE(terminalid,'') AS terminalid, COUNT(*) AS billcount,
|
||||||
|
COALESCE(SUM(total),0) AS amount
|
||||||
|
FROM pos_orders WHERE %s
|
||||||
|
GROUP BY terminalid ORDER BY amount DESC`, where),
|
||||||
|
params...).Scan(&summary.Byterminal).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -465,7 +465,13 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
|
|||||||
// into Locationproducts.Quantity, which is what made the console's stock
|
// into Locationproducts.Quantity, which is what made the console's stock
|
||||||
// column look frozen after an order despite CreateOrder recording the
|
// column look frozen after an order despite CreateOrder recording the
|
||||||
// "out" ledger entry correctly.
|
// "out" ledger entry correctly.
|
||||||
query := `SELECT a.*, b.productlocationid, b.status, b.price,
|
// COALESCE so `price` means the same thing here as in GetProducts: the
|
||||||
|
// effective selling price at this outlet, falling back to the master
|
||||||
|
// retailprice when the store hasn't set its own. Returning a bare b.price
|
||||||
|
// reported 0 for any product priced only at tenant level, which the store
|
||||||
|
// catalogue then rendered as "—".
|
||||||
|
query := `SELECT a.*, b.productlocationid, b.status,
|
||||||
|
COALESCE(NULLIF(b.price, 0), a.retailprice, 0) AS price,
|
||||||
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' THEN c.quantity ELSE 0 END), 0) AS total_in,
|
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' THEN c.quantity ELSE 0 END), 0) AS total_in,
|
||||||
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' THEN c.quantity ELSE 0 END), 0) AS total_out,
|
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' THEN c.quantity ELSE 0 END), 0) AS total_out,
|
||||||
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' THEN c.quantity ELSE 0 END) -
|
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' THEN c.quantity ELSE 0 END) -
|
||||||
@@ -842,7 +848,7 @@ func (r *productRepository) GetProducts(params models.ProductFilter) ([]models.P
|
|||||||
var products []models.Products
|
var products []models.Products
|
||||||
|
|
||||||
q := r.db.Table("products a").
|
q := r.db.Table("products a").
|
||||||
Joins("LEFT JOIN productlocations pl ON pl.productid = a.productid").
|
Joins("LEFT JOIN productlocations pl ON pl.productid = a.productid AND pl.tenantid = a.tenantid").
|
||||||
Joins("LEFT JOIN productdiscounts pd ON pd.productid = a.productid").
|
Joins("LEFT JOIN productdiscounts pd ON pd.productid = a.productid").
|
||||||
Joins("LEFT JOIN productcategories c ON a.categoryid = c.categoryid").
|
Joins("LEFT JOIN productcategories c ON a.categoryid = c.categoryid").
|
||||||
Where("a.categoryid = ?", params.CategoryID)
|
Where("a.categoryid = ?", params.CategoryID)
|
||||||
@@ -875,9 +881,26 @@ func (r *productRepository) GetProducts(params models.ProductFilter) ([]models.P
|
|||||||
// never-decremented products.quantity column — same fix as
|
// never-decremented products.quantity column — same fix as
|
||||||
// GetLocationProducts/GetProductByVariant, otherwise this endpoint would
|
// GetLocationProducts/GetProductByVariant, otherwise this endpoint would
|
||||||
// keep showing stock that never reduces after an order.
|
// keep showing stock that never reduces after an order.
|
||||||
|
// price is the effective selling price at params.LocationID: the store's own
|
||||||
|
// productlocations.price, falling back to the master products.retailprice
|
||||||
|
// when that outlet hasn't set one. It has to be here — this endpoint feeds
|
||||||
|
// the customer app's browse-by-subcategory view, and `a.*` only carries
|
||||||
|
// retailprice, which the admin catalogue never writes. So a price the admin
|
||||||
|
// set per store could never reach the app; every product priced as 0.
|
||||||
|
//
|
||||||
|
// Deliberately a correlated subquery rather than a read off the joined `pl`:
|
||||||
|
// that join isn't outlet-scoped unless params.LocationID is set, so reading
|
||||||
|
// pl.price directly would pick an arbitrary branch's price (and multiply the
|
||||||
|
// rows) whenever the caller didn't scope to one. Same shape as the
|
||||||
|
// productstock subqueries below, for the same reason.
|
||||||
err := q.Select(`
|
err := q.Select(`
|
||||||
a.*,
|
a.*,
|
||||||
COALESCE(pd.discountvalue, 0) AS discountvalue,
|
COALESCE(pd.discountvalue, 0) AS discountvalue,
|
||||||
|
COALESCE(NULLIF((
|
||||||
|
SELECT pl2.price FROM productlocations pl2
|
||||||
|
WHERE pl2.productid = a.productid AND pl2.tenantid = a.tenantid AND pl2.locationid = ?
|
||||||
|
LIMIT 1
|
||||||
|
), 0), a.retailprice, 0) AS price,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT SUM(CASE WHEN LOWER(ps.stocktype) = 'in' THEN ps.quantity ELSE 0 END) -
|
SELECT SUM(CASE WHEN LOWER(ps.stocktype) = 'in' THEN ps.quantity ELSE 0 END) -
|
||||||
SUM(CASE WHEN LOWER(ps.stocktype) = 'out' THEN ps.quantity ELSE 0 END)
|
SUM(CASE WHEN LOWER(ps.stocktype) = 'out' THEN ps.quantity ELSE 0 END)
|
||||||
@@ -890,7 +913,7 @@ func (r *productRepository) GetProducts(params models.ProductFilter) ([]models.P
|
|||||||
FROM productstocks ps
|
FROM productstocks ps
|
||||||
WHERE ps.productid = a.productid AND ps.tenantid = a.tenantid AND ps.locationid = ?
|
WHERE ps.productid = a.productid AND ps.tenantid = a.tenantid AND ps.locationid = ?
|
||||||
), 0) AS quantity
|
), 0) AS quantity
|
||||||
`, params.LocationID, params.LocationID).Find(&products).Error
|
`, params.LocationID, params.LocationID, params.LocationID).Find(&products).Error
|
||||||
|
|
||||||
return products, err
|
return products, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -120,13 +130,15 @@ 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,13 +20,90 @@ 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
|
||||||
|
// these a committed bill is unreachable from every screen in the product.
|
||||||
|
pos.Get("/sales", f.PosController.GetSales)
|
||||||
|
pos.Get("/sales/detail", f.PosController.GetSaleDetail)
|
||||||
|
pos.Get("/sales/summary", f.PosController.GetSalesSummary)
|
||||||
|
|
||||||
// Terminal presence, read from Redis. What the rider app's POS board and a
|
// Terminal presence, read from Redis. What the rider app's POS board and a
|
||||||
// support call both hit — the tills themselves publish health over the
|
// support call both hit — the tills themselves publish health over the
|
||||||
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import (
|
|||||||
// The two test bills are named explicitly rather than deleted by date or by
|
// The two test bills are named explicitly rather than deleted by date or by
|
||||||
// "everything in pos_orders" — a table that will hold real takings tomorrow is
|
// "everything in pos_orders" — a table that will hold real takings tomorrow is
|
||||||
// not one to run an unbounded DELETE against.
|
// not one to run an unbounded DELETE against.
|
||||||
const probeMobile = "9840012345"
|
// Every mobile the probes registered, so a cleanup run leaves nothing behind.
|
||||||
|
var probeMobiles = []string{"9840012345", "9840099999", "9840077777"}
|
||||||
|
|
||||||
var testOrderIDs = []string{
|
var testOrderIDs = []string{
|
||||||
"11111111-2222-4333-8444-555555555555", // the HTTP probe
|
"11111111-2222-4333-8444-555555555555", // the HTTP probe
|
||||||
@@ -70,16 +71,21 @@ func cleanup(db *gorm.DB) {
|
|||||||
|
|
||||||
// The shopper the probes created. Removed only when nothing references it —
|
// The shopper the probes created. Removed only when nothing references it —
|
||||||
// a customer row attached to a real order is not test data any more.
|
// a customer row attached to a real order is not test data any more.
|
||||||
var referenced int
|
for _, mobile := range probeMobiles {
|
||||||
db.Raw(`SELECT COUNT(*) FROM orders WHERE customerid =
|
// A customer row attached to a real order is not test data any more.
|
||||||
(SELECT MIN(customerid) FROM customers WHERE contactno = ?)`,
|
var referenced int
|
||||||
probeMobile).Scan(&referenced)
|
db.Raw(`SELECT COUNT(*) FROM orders WHERE customerid IN
|
||||||
if referenced > 0 {
|
(SELECT customerid FROM customers WHERE contactno = ?)`,
|
||||||
fmt.Printf(" customer %s left in place — %d order(s) reference it\n",
|
mobile).Scan(&referenced)
|
||||||
probeMobile, referenced)
|
if referenced > 0 {
|
||||||
} else {
|
fmt.Printf(" customer %s left in place — %d order(s) reference it\n",
|
||||||
res := db.Exec(`DELETE FROM customers WHERE contactno = ?`, probeMobile)
|
mobile, referenced)
|
||||||
fmt.Printf(" removed %d probe customer row(s)\n", res.RowsAffected)
|
continue
|
||||||
|
}
|
||||||
|
res := db.Exec(`DELETE FROM customers WHERE contactno = ?`, mobile)
|
||||||
|
if res.RowsAffected > 0 {
|
||||||
|
fmt.Printf(" removed probe customer %s\n", mobile)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var balance float64
|
var balance float64
|
||||||
|
|||||||
@@ -153,6 +153,26 @@ func main() {
|
|||||||
case "cleanup":
|
case "cleanup":
|
||||||
cleanup(db)
|
cleanup(db)
|
||||||
|
|
||||||
|
case "columns":
|
||||||
|
for _, t := range []string{"products", "productlocations", "productstocks"} {
|
||||||
|
fmt.Printf("=== %s ===\n", t)
|
||||||
|
var cols []struct {
|
||||||
|
ColumnName string
|
||||||
|
DataType string
|
||||||
|
}
|
||||||
|
db.Raw(`SELECT column_name, data_type FROM information_schema.columns
|
||||||
|
WHERE table_name = ? ORDER BY ordinal_position`, t).Scan(&cols)
|
||||||
|
for _, c := range cols {
|
||||||
|
marker := ""
|
||||||
|
n := c.ColumnName
|
||||||
|
if n == "created" || n == "updated" || n == "updated_at" ||
|
||||||
|
n == "stockdate" || n == "modified" {
|
||||||
|
marker = " <-- timestamp"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-24s %s%s\n", c.ColumnName, c.DataType, marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "customer":
|
case "customer":
|
||||||
fmt.Println("=== customers matching the uplink probe ===")
|
fmt.Println("=== customers matching the uplink probe ===")
|
||||||
showCustomer(db, "9840012345")
|
showCustomer(db, "9840012345")
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,5 +118,31 @@ func main() {
|
|||||||
log.Fatal("publish health:", t.Error())
|
log.Fatal("publish health:", t.Error())
|
||||||
}
|
}
|
||||||
fmt.Println("\npublished a heartbeat to", healthTopic)
|
fmt.Println("\npublished a heartbeat to", healthTopic)
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
|
// The registration uplink. Tested over HTTP early on; this is the same
|
||||||
|
// service reached over the broker, which is the path a real till uses.
|
||||||
|
custBatch, _ := json.Marshal(map[string]any{
|
||||||
|
"schema": 1, "batch_id": "batch-cust-mqtt-0001",
|
||||||
|
"store_id": locationID, "terminal_id": terminalID,
|
||||||
|
"customers": []map[string]any{{
|
||||||
|
"id": "3d7a0000-0000-4000-8000-000000000001",
|
||||||
|
"mobile": "9840077777",
|
||||||
|
"name": "MQTT Probe Shopper",
|
||||||
|
"registered_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"registered_by_terminal": terminalID,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
custTopic := fmt.Sprintf("nearle/pos/%s/%s/customer", locationID, terminalID)
|
||||||
|
if t := client.Publish(custTopic, 1, false, custBatch); t.Wait() && t.Error() != nil {
|
||||||
|
log.Fatal("publish customer:", t.Error())
|
||||||
|
}
|
||||||
|
fmt.Println("published a registration to", custTopic)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case payload := <-acks:
|
||||||
|
fmt.Println(" registration ACK:", string(payload))
|
||||||
|
case <-time.After(20 * time.Second):
|
||||||
|
fmt.Println(" NO ACK for the registration")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
||||||
@@ -19,6 +21,37 @@ type PosService interface {
|
|||||||
|
|
||||||
TerminalHealth(ctx context.Context, terminalID string) (map[string]string, error)
|
TerminalHealth(ctx context.Context, terminalID string) (map[string]string, error)
|
||||||
LocationHealth(ctx context.Context, locationID string) ([]map[string]string, error)
|
LocationHealth(ctx context.Context, locationID string) ([]map[string]string, error)
|
||||||
|
|
||||||
|
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
|
||||||
|
SaleDetail(locationID int, reference string) (*models.PosOrders, 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 {
|
||||||
@@ -53,3 +86,93 @@ func (s *posService) IngestCustomers(batch models.PosCustomerBatch) (*models.Pos
|
|||||||
func (s *posService) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
|
func (s *posService) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
|
||||||
return s.repo.Catalogue(storeID, since, page, pageSize)
|
return s.repo.Catalogue(storeID, since, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *posService) Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) {
|
||||||
|
return s.repo.Sales(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *posService) SaleDetail(locationID int, reference string) (*models.PosOrders, error) {
|
||||||
|
return s.repo.SaleDetail(locationID, reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *posService) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -293,6 +293,11 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Price carries the selling price onto the per-store row. Omitting it
|
||||||
|
// left productlocations.price at 0 for every imported product, and that
|
||||||
|
// column — not products.retailprice — is what the store catalogue, the
|
||||||
|
// customer app and each order line read. The result was a catalogue
|
||||||
|
// where nothing had a price and every order booked an amount of 0.
|
||||||
locations = append(locations, models.Productlocations{
|
locations = append(locations, models.Productlocations{
|
||||||
Tenantid: req.Tenantid,
|
Tenantid: req.Tenantid,
|
||||||
Locationid: req.Locationid,
|
Locationid: req.Locationid,
|
||||||
@@ -300,6 +305,7 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro
|
|||||||
Quantity: req.Quantity,
|
Quantity: req.Quantity,
|
||||||
Stocktype: req.Stocktype,
|
Stocktype: req.Stocktype,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
|
Price: float32(req.Retailprice),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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