package controllers import ( "log" "net/http" "strconv" "strings" "nearle/models" "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) } // 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, }, }) } // 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, }) }