diff --git a/controllers/posController.go b/controllers/posController.go index f51dcb2..21a87b6 100644 --- a/controllers/posController.go +++ b/controllers/posController.go @@ -181,6 +181,12 @@ func (ctl *PosController) Catalogue(c *fiber.Ctx) error { // TerminalHealth returns one till's live state, for a support call that starts // with a terminal code. +// +// The outlet check cannot live in the middleware like every other POS route's +// does. The middleware scopes a request by the location it *names*, and this +// request names none — only a terminal code, which is free text minted at the +// till and belongs to whichever shop is holding that device. So the outlet is +// not known until after the lookup, and the check has to happen here. func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error { terminalID := strings.TrimSpace(c.Query("terminal_id")) if terminalID == "" { @@ -200,6 +206,11 @@ func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error { // 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. + // + // Answered without an outlet check, and safely so: there is nothing to + // check against and nothing to leak. "Offline" is the same answer for a + // terminal code that was never issued, so guessing codes reveals only + // that guessing does not work. return c.JSON(fiber.Map{ "code": http.StatusOK, "status": true, @@ -211,9 +222,54 @@ func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error { }) } + if err := ctl.posTerminalInScope(c, fields); err != nil { + 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, "details": fields}) } +// posTerminalInScope refuses a heartbeat belonging to somebody else's shop. +// +// Reads the outlet off the heartbeat itself, because that — not the request — +// is the authority on which shop a terminal code belongs to. A caller who +// guesses "T4A9" gets a 403 rather than another shop's pending-bill count, +// takings so far today, and app version. +// +// Silent when the request carries no token, matching middleware.PosAuth: while +// POS_AUTH_REQUIRED is off, tills in the field are still calling these routes +// unauthenticated, and refusing them here would take the fleet board down for +// exactly the terminals it exists to watch. Once the flag is on, an untokened +// request never reaches this handler. +func (ctl *PosController) posTerminalInScope(c *fiber.Ctx, fields map[string]string) error { + claims, ok := middleware.PosClaimsFrom(c) + if !ok { + return nil + } + + locationID, err := strconv.Atoi(strings.TrimSpace(fields["location_id"])) + if err != nil || locationID <= 0 { + // A heartbeat that cannot say where it came from cannot be shown to a + // caller who must be scoped. Refusing beats guessing. + return fmt.Errorf("this terminal's outlet could not be determined") + } + + if locationID == claims.Locationid { + return nil + } + + allowed, err := ctl.posService.LocationAllowed(claims.Tenantid, locationID) + if err != nil { + return fmt.Errorf("could not verify outlet access") + } + if !allowed { + return fmt.Errorf("this terminal belongs to an outlet this session cannot reach") + } + return nil +} + // 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. diff --git a/scratch/termaudit/main.go b/scratch/termaudit/main.go new file mode 100644 index 0000000..7d980f7 --- /dev/null +++ b/scratch/termaudit/main.go @@ -0,0 +1,64 @@ +// Read-only audit of terminalid coverage on pos_orders. +// +// Answers one question: are blank terminal codes still arriving, or do they all +// predate the posTerminalFor fix? Touches nothing. +// +// go run ./scratch/termaudit +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) + } + + var rows []struct { + Day string + Blank int + Named int + Invoice string + } + db.Raw(`SELECT to_char(receivedat,'YYYY-MM-DD') AS day, + COUNT(*) FILTER (WHERE COALESCE(terminalid,'') = '') AS blank, + COUNT(*) FILTER (WHERE COALESCE(terminalid,'') <> '') AS named, + MIN(invoicenumber) AS invoice + FROM pos_orders GROUP BY 1 ORDER BY 1`).Scan(&rows) + + fmt.Printf("%-12s %7s %7s %s\n", "received", "blank", "named", "sample invoice") + for _, r := range rows { + fmt.Printf("%-12s %7d %7d %s\n", r.Day, r.Blank, r.Named, r.Invoice) + } + + var last struct { + Invoicenumber string + Terminalid string + Receivedat string + } + db.Raw(`SELECT invoicenumber, COALESCE(terminalid,'') AS terminalid, + to_char(receivedat,'YYYY-MM-DD HH24:MI') AS receivedat + FROM pos_orders WHERE COALESCE(terminalid,'') = '' + ORDER BY receivedat DESC LIMIT 1`).Scan(&last) + fmt.Printf("\nnewest blank bill: %s received %s\n", last.Invoicenumber, last.Receivedat) + + // Does the invoice number itself carry the terminal code? INV-2608-T5EDD-00116 + var recoverable int + db.Raw(`SELECT COUNT(*) FROM pos_orders + WHERE COALESCE(terminalid,'') = '' + AND invoicenumber ~ '^INV-[0-9]+-[A-Z0-9]+-[0-9]+$'`).Scan(&recoverable) + fmt.Printf("blank bills whose invoice number encodes a terminal: %d\n", recoverable) +} diff --git a/scratch/termbackfill/main.go b/scratch/termbackfill/main.go new file mode 100644 index 0000000..a3bf818 --- /dev/null +++ b/scratch/termbackfill/main.go @@ -0,0 +1,132 @@ +// Backfills terminalid on bills that arrived before the posTerminalFor fix. +// +// Between the till and v1.3.96 the ingest wrote the fallback terminal code onto +// the batch while the row was built from the order, so bills landed with an +// empty terminalid and `byterminal` grouped them all under "". The code was +// never lost — it is the third segment of the invoice number the till printed, +// INV-2608-T5EDD-00116, minted at the same terminal in the same transaction. +// So this derives rather than guesses. +// +// Only rows whose terminalid is blank AND whose invoice number matches the +// exact shape are touched; anything else is left alone and reported. +// +// go run ./scratch/termbackfill # dry run — shows every change +// go run ./scratch/termbackfill apply # writes, then prints the undo +package main + +import ( + "fmt" + "log" + "os" + + "github.com/joho/godotenv" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Anchored, and the terminal segment is required to be non-empty. A loose +// pattern here would write a blank over a blank and report it as a fix. +const invoiceShape = `^INV-[0-9]+-[A-Za-z0-9]+-[0-9]+$` + +type row struct { + Posorderid int + Invoicenumber string + Locationid int + Derived string + Businessdate string +} + +func main() { + apply := len(os.Args) > 1 && os.Args[1] == "apply" + + _ = 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 blank int + db.Raw(`SELECT COUNT(*) FROM pos_orders WHERE COALESCE(terminalid,'') = ''`).Scan(&blank) + + var rows []row + db.Raw(`SELECT posorderid, invoicenumber, locationid, businessdate, + split_part(invoicenumber, '-', 3) AS derived + FROM pos_orders + WHERE COALESCE(terminalid,'') = '' AND invoicenumber ~ ? + ORDER BY posorderid`, invoiceShape).Scan(&rows) + + fmt.Printf("bills with a blank terminalid: %d\n", blank) + fmt.Printf("of those, recoverable from the invoice number: %d\n\n", len(rows)) + if len(rows) != blank { + fmt.Printf("!! %d bill(s) cannot be recovered and will be left blank\n\n", blank-len(rows)) + } + + // A derived code must already be in use at the same outlet, or it is not a + // terminal — it is a segment that happened to parse. A code that fails is + // skipped, not fatal: it means one till's bills are *all* blank, which no + // amount of cross-referencing can confirm from this table alone. Writing it + // anyway would launder a guess into the ledger as though it were recovered. + counts := map[string]int{} + for _, r := range rows { + counts[fmt.Sprintf("%d/%s", r.Locationid, r.Derived)]++ + } + trusted := map[string]bool{} + seen := map[string]bool{} + + fmt.Printf("%-10s %-9s %-6s %-12s %s\n", "location", "terminal", "bills", "date", "known at this outlet?") + for _, r := range rows { + key := fmt.Sprintf("%d/%s", r.Locationid, r.Derived) + if seen[key] { + continue + } + seen[key] = true + + var known int + db.Raw(`SELECT COUNT(*) FROM pos_orders + WHERE locationid = ? AND terminalid = ?`, r.Locationid, r.Derived).Scan(&known) + trusted[key] = known > 0 + + verdict := fmt.Sprintf("yes — %d bill(s) already filed under it", known) + if known == 0 { + verdict = "NO — never seen here, SKIPPED" + } + fmt.Printf("%-10d %-9s %-6d %-12s %s\n", r.Locationid, r.Derived, counts[key], r.Businessdate, verdict) + } + + ids := make([]int, 0, len(rows)) + skipped := 0 + for _, r := range rows { + if trusted[fmt.Sprintf("%d/%s", r.Locationid, r.Derived)] { + ids = append(ids, r.Posorderid) + continue + } + skipped++ + } + fmt.Printf("\nwill update %d bill(s); leaving %d blank\n", len(ids), skipped) + + if len(ids) == 0 { + return + } + if !apply { + fmt.Println("dry run — nothing written. re-run with `apply` to write.") + return + } + + res := db.Exec(`UPDATE pos_orders + SET terminalid = split_part(invoicenumber, '-', 3) + WHERE posorderid IN ? AND COALESCE(terminalid,'') = ''`, ids) + if res.Error != nil { + log.Fatal(res.Error) + } + fmt.Printf("\nupdated %d bill(s)\n", res.RowsAffected) + + var stillBlank int + db.Raw(`SELECT COUNT(*) FROM pos_orders WHERE COALESCE(terminalid,'') = ''`).Scan(&stillBlank) + fmt.Printf("bills still blank: %d\n", stillBlank) + + fmt.Printf("\nundo:\n UPDATE pos_orders SET terminalid = '' WHERE posorderid IN (%v);\n", ids) +}