The POS surface was open. A till named its own outlet — `store_id` in a query or in an ingest batch — and was believed, so one number changed in Settings read another tenant's catalogue or posted 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. Products were never mis-scoped: `resolvePosStore` already derived the tenant from the location and the catalogue query already filtered on both. The tenant was never taken from the wire. What was missing was any check that the caller was entitled to the location they named. So the outlet now comes *out* of a sign-in rather than going *in* from the till. `POST /pos/login` authenticates against the same `app_users` rows the web console uses — one account store, so deactivating a leaver closes both doors — and answers with the outlets that account may reach, sealed in an HMAC-SHA256 token the terminal cannot edit. Two checks then guard everything else, in order: the token verifies, and 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. Notes on the awkward parts: - The guard reads the outlet from the body as well as the query. The two routes that write carry `store_id` in a JSON batch and never in the URL, so a query-only check would have left exactly the dangerous call unguarded. - Three spellings of one thing survive — `store_id`, `locationid`, `location_id`. All three are read rather than normalised, because renaming them breaks terminals already in the field. - `POS_AUTH_REQUIRED` defaults to false. Tills are billing real customers against the open endpoints right now and enforcing at deploy would stop every one mid-trade. A token is still verified when sent, and a wrong-tenant token still refused; the flag only governs requests carrying none. - `POS_TOKEN_SECRET` has no baked-in fallback and fails loudly. A development secret in source is the same as no signature at all. - `configid` is inferred when the till does not send it, because a person at a counter has no way to know theirs. `authname` is not unique in this schema — live data has one address twice under one configid — so an ambiguous match is refused rather than resolved by LIMIT 1, which could bill into the wrong tenant's books. Verified against live data: 58 accounts across 34 tenants can open a till, an account pinned to a location resolves to it alone, a tenant-level account gets all six of its outlets, and a cross-tenant outlet request is refused. Passwords are still plaintext platform-wide. Flagged at the comparison site; fixing it is a migration touching every login path, not this endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
456 lines
15 KiB
Go
456 lines
15 KiB
Go
package controllers
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"nearle/middleware"
|
|
"nearle/models"
|
|
"nearle/repositories"
|
|
"nearle/services"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// HTTP face of the POS terminal ingest.
|
|
//
|
|
// These handlers break this codebase's house style in one respect, on purpose:
|
|
// they answer with a bare ack rather than the usual
|
|
// `{code, message, status, details}` envelope. The terminal reads `accepted`
|
|
// from the top level of the body and marks a bill synced only if its id is
|
|
// there — wrapping the ack would leave every till queueing for ever.
|
|
//
|
|
// The status code carries the other half of the contract:
|
|
//
|
|
// - **200** — the batch was processed. Individual bills may still have been
|
|
// refused; the ack says which.
|
|
// - **4xx** — the request itself is wrong (unreadable body, unknown outlet).
|
|
// The terminal treats these as non-retryable and halts, so a person is
|
|
// told rather than the broker hammered.
|
|
// - **5xx** — the outcome is unknown. The terminal keeps every bill and
|
|
// retries with backoff. This is the right answer when the database is
|
|
// having a bad minute: *never* ack a batch that did not commit.
|
|
type PosController struct {
|
|
posService services.PosService
|
|
}
|
|
|
|
func NewPosController(posService services.PosService) *PosController {
|
|
return &PosController{posService: posService}
|
|
}
|
|
|
|
// IngestOrders receives a batch of completed counter bills.
|
|
func (ctl *PosController) IngestOrders(c *fiber.Ctx) error {
|
|
var batch models.PosOrderBatch
|
|
|
|
if err := c.BodyParser(&batch); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "could not read the batch: " + err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
if strings.TrimSpace(batch.Storeid) == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "store_id is required",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
ack, err := ctl.posService.IngestOrders(batch)
|
|
if err != nil {
|
|
return posIngestError(c, "IngestOrders", err)
|
|
}
|
|
|
|
return c.Status(http.StatusOK).JSON(ack)
|
|
}
|
|
|
|
// IngestCustomers receives shoppers registered at a till.
|
|
func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error {
|
|
var batch models.PosCustomerBatch
|
|
|
|
if err := c.BodyParser(&batch); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "could not read the batch: " + err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
if strings.TrimSpace(batch.Storeid) == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "store_id is required",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
ack, err := ctl.posService.IngestCustomers(batch)
|
|
if err != nil {
|
|
return posIngestError(c, "IngestCustomers", err)
|
|
}
|
|
|
|
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.
|
|
func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
|
|
storeID := strings.TrimSpace(c.Query("store_id"))
|
|
if storeID == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "store_id is required",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
page, _ := strconv.Atoi(c.Query("page", "0"))
|
|
pageSize, _ := strconv.Atoi(c.Query("page_size", "500"))
|
|
|
|
result, err := ctl.posService.Catalogue(storeID, c.Query("since"), page, pageSize)
|
|
if err != nil {
|
|
return posIngestError(c, "Catalogue", err)
|
|
}
|
|
|
|
return c.Status(http.StatusOK).JSON(result)
|
|
}
|
|
|
|
// TerminalHealth returns one till's live state, for a support call that starts
|
|
// with a terminal code.
|
|
func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
|
|
terminalID := strings.TrimSpace(c.Query("terminal_id"))
|
|
if terminalID == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest, "message": "terminal_id is required", "status": false,
|
|
})
|
|
}
|
|
|
|
fields, err := ctl.posService.TerminalHealth(c.Context(), terminalID)
|
|
if err != nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
|
|
"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
|
|
})
|
|
}
|
|
|
|
if fields == nil {
|
|
// Not an error. The till has simply not reported inside its TTL, which
|
|
// is the answer the caller wanted — said plainly rather than as a 404
|
|
// that reads like the terminal does not exist.
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"status": true,
|
|
"details": fiber.Map{
|
|
"terminal_id": terminalID,
|
|
"status": "offline",
|
|
"reason": "no heartbeat received within the presence window",
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields})
|
|
}
|
|
|
|
// LocationHealth returns every till at a shop — the "which counters are dark"
|
|
// board. Tills that have stopped reporting come back marked offline rather than
|
|
// being omitted, because a missing till is exactly what somebody is looking for.
|
|
func (ctl *PosController) LocationHealth(c *fiber.Ctx) error {
|
|
locationID := strings.TrimSpace(c.Query("location_id"))
|
|
if locationID == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest, "message": "location_id is required", "status": false,
|
|
})
|
|
}
|
|
|
|
terminals, err := ctl.posService.LocationHealth(c.Context(), locationID)
|
|
if err != nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
|
|
"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
|
|
})
|
|
}
|
|
|
|
online := 0
|
|
for _, t := range terminals {
|
|
if t["status"] == "online" {
|
|
online++
|
|
}
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"status": true,
|
|
"details": fiber.Map{
|
|
"location_id": locationID,
|
|
"total": len(terminals),
|
|
"online": online,
|
|
"terminals": terminals,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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.
|
|
//
|
|
// The distinction matters more than the message does. A misconfigured store id
|
|
// will be just as wrong on the next attempt, so it is reported as a 4xx and the
|
|
// till halts and shows a person the reason. Anything else might succeed later,
|
|
// so it is a 5xx and the bills stay queued.
|
|
func posIngestError(c *fiber.Ctx, op string, err error) error {
|
|
log.Printf("pos %s: %v", op, err)
|
|
|
|
message := err.Error()
|
|
lower := strings.ToLower(message)
|
|
|
|
permanent := strings.Contains(lower, "is not a location id") ||
|
|
strings.Contains(lower, "no outlet is registered") ||
|
|
strings.Contains(lower, "does not belong to tenant") ||
|
|
strings.Contains(lower, "has no products stocked") ||
|
|
strings.Contains(lower, "no applocationid configured")
|
|
|
|
status := http.StatusInternalServerError
|
|
if permanent {
|
|
status = http.StatusBadRequest
|
|
}
|
|
|
|
return c.Status(status).JSON(fiber.Map{
|
|
"code": status,
|
|
"message": message,
|
|
"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),
|
|
},
|
|
})
|
|
}
|