package controllers import ( "errors" "fmt" "log" "net/http" "strconv" "strings" "time" "nearle/middleware" "nearle/models" "nearle/repositories" "nearle/services" "nearle/utils" "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, }, }) } // ------------------------------------------------------------- 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) }