The terminal shipped with three names and three PINs compiled into it. Same three on every install, readable by anyone with the APK, and permanent — nothing anywhere could replace them. `/pos/staff` answers with the people the back office says may ring a bill at an outlet, and the same list rides down with the session so a till is ready to trade the moment it signs in. The terminal writes them over its own and deactivates whatever it had, which is what actually kills the seeded logins. Two sources are 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. Either alone returns nothing for almost every shop. The endpoint takes no location parameter. The answer carries PINs, so the outlet comes from the caller's token and a request without one is refused whatever POS_AUTH_REQUIRED says — a till must not be able to ask who works at the shop next door. Rows with no PIN are dropped rather than sent: a name on screen nobody can sign in as reads as a broken terminal rather than as an unfinished setup. Duplicate PINs are dropped too, keeping the first — live data has 1234 on eleven accounts and 1111 on nine, and two people sharing one would make the till attribute a bill to whichever row it checked first. The PIN travels in the clear over TLS, deliberately. Four digits are brute-forceable in microseconds however they are wrapped, so hashing here would buy the appearance of strength and not the substance — while costing something real, since the terminal salts every PIN with its own salt before storing it and could never verify a hash computed here. A PIN is shift attribution, not a security boundary; the boundary is the session token. Verified against live data, and it says the fallback still matters: outlet 1135 — the one the POS actually uses — has zero staff, and the only staff row found anywhere is a delivery rider on PIN 1111. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
485 lines
16 KiB
Go
485 lines
16 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),
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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,
|
|
},
|
|
})
|
|
}
|