Files
backend_fiesta/middleware/posauthadoption.go
2026-09-30 18:01:59 +05:30

250 lines
9.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middleware
import (
"context"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"
)
/*
How much of the till fleet is carrying a session token.
── Why this exists ─────────────────────────────────────────────────────────
`POS_AUTH_REQUIRED` is off, and the only thing standing between here and
switching it on is a number nobody has: how many terminals still call the POS
routes with no token. Flipping the flag blind is the one action on this surface
that can stop a shop trading mid-queue — a cashier at a counter unable to ring a
bill is not a reversible inconvenience.
So this counts, and names the outlets that are still untokened, so the flag gets
flipped on evidence rather than on hope.
── What it deliberately is not ─────────────────────────────────────────────
Not persisted. It lives in memory and resets on restart, which is honest about
what it measures: adoption since this process started, not all time. A restart
mid-observation means starting the week again, and that is a smaller cost than a
migration and a table for a number that stops mattering the day the flag is on.
Not a rate limiter and not a gate. It records; it never refuses. Nothing in
here can change whether a request is served.
Capped. `store_id` comes off the wire, so an untokened caller could otherwise
name ten thousand outlets and grow this without bound. Past the cap new outlets
are counted in the totals and not listed individually, which keeps the answer
useful without making it a way to spend the server's memory.
*/
// posAdoptionCap is how many distinct untokened outlets are named individually.
// The real fleet is dozens; anything beyond this is noise or somebody probing.
const posAdoptionCap = 200
type posOutletSeen struct {
Locationid int
Requests int64
FirstSeen time.Time
LastSeen time.Time
}
var posAdoption = struct {
sync.Mutex
since time.Time
tokened int64
untokened int64
// Untokened requests by the outlet they named, and by the route they hit.
outlets map[int]*posOutletSeen
paths map[string]int64
// True once the cap was reached, so the report can say it is partial
// rather than quietly under-reporting.
truncated bool
}{
since: time.Now(),
outlets: map[int]*posOutletSeen{},
paths: map[string]int64{},
}
// recordPosToken notes one request that arrived with a usable token.
func recordPosToken() {
posAdoption.Lock()
posAdoption.tokened++
posAdoption.Unlock()
}
// recordPosUntokened notes one request that arrived with none, and where it
// claimed to be. `locationid` is 0 when the route named no outlet.
func recordPosUntokened(locationid int, path string) {
now := time.Now()
posAdoption.Lock()
defer posAdoption.Unlock()
posAdoption.untokened++
posAdoption.paths[path]++
if locationid <= 0 {
return
}
if seen, ok := posAdoption.outlets[locationid]; ok {
seen.Requests++
seen.LastSeen = now
return
}
if len(posAdoption.outlets) >= posAdoptionCap {
posAdoption.truncated = true
return
}
posAdoption.outlets[locationid] = &posOutletSeen{
Locationid: locationid, Requests: 1, FirstSeen: now, LastSeen: now,
}
}
// PosAdoptionOutlet is one outlet still calling without a token.
type PosAdoptionOutlet struct {
Locationid int `json:"locationid"`
Requests int64 `json:"requests"`
FirstSeen string `json:"firstseen"`
LastSeen string `json:"lastseen"`
}
// PosAdoptionPath is one route, and how often it was reached untokened.
type PosAdoptionPath struct {
Path string `json:"path"`
Requests int64 `json:"requests"`
}
// PosAdoption is the answer to "is it safe to switch enforcement on yet".
type PosAdoption struct {
// Whether an untokened request is currently refused.
Enforced bool `json:"enforced"`
// When counting started — process start, not all time.
Since string `json:"since"`
// Requests seen on the POS surface since then.
Tokened int64 `json:"tokened"`
Untokened int64 `json:"untokened"`
// 0–100. 100 means every request in this window carried a token, which is
// the condition for flipping the flag.
AdoptedPercent float64 `json:"adoptedpercent"`
// The outlets still calling without one, busiest first. These are the tills
// that would stop working the moment enforcement is switched on.
Outlets []PosAdoptionOutlet `json:"outlets"`
// Which routes they are reaching, busiest first.
Paths []PosAdoptionPath `json:"paths"`
// True when more outlets were seen than are listed — see posAdoptionCap.
Truncated bool `json:"truncated"`
// Plain-language reading of the above, for whoever has to make the call.
Verdict string `json:"verdict"`
}
// PosAdoptionReport is the snapshot, safe to call at any time.
func PosAdoptionReport() PosAdoption {
posAdoption.Lock()
defer posAdoption.Unlock()
report := PosAdoption{
Enforced: posAuthRequired(),
Since: posAdoption.since.Format(time.RFC3339),
Tokened: posAdoption.tokened,
Untokened: posAdoption.untokened,
Truncated: posAdoption.truncated,
Outlets: make([]PosAdoptionOutlet, 0, len(posAdoption.outlets)),
Paths: make([]PosAdoptionPath, 0, len(posAdoption.paths)),
}
total := posAdoption.tokened + posAdoption.untokened
if total > 0 {
report.AdoptedPercent = float64(posAdoption.tokened) * 100 / float64(total)
}
for _, seen := range posAdoption.outlets {
report.Outlets = append(report.Outlets, PosAdoptionOutlet{
Locationid: seen.Locationid,
Requests: seen.Requests,
FirstSeen: seen.FirstSeen.Format(time.RFC3339),
LastSeen: seen.LastSeen.Format(time.RFC3339),
})
}
// Busiest first: the outlet ringing the most bills is the one that hurts
// most if enforcement switches on before it has adopted.
sort.Slice(report.Outlets, func(i, j int) bool {
return report.Outlets[i].Requests > report.Outlets[j].Requests
})
for path, count := range posAdoption.paths {
report.Paths = append(report.Paths, PosAdoptionPath{Path: path, Requests: count})
}
sort.Slice(report.Paths, func(i, j int) bool {
return report.Paths[i].Requests > report.Paths[j].Requests
})
report.Verdict = posAdoptionVerdict(report)
return report
}
// posAdoptionVerdict says what the numbers mean, because the number on its own
// invites the wrong reading in both directions: a clean window that is only an
// hour long proves nothing, and one stubborn outlet is not a reason to leave
// the whole surface open.
func posAdoptionVerdict(r PosAdoption) string {
switch {
case r.Enforced:
return "Enforcement is already on: an untokened request is refused."
case r.Tokened+r.Untokened == 0:
return "No POS traffic seen since this process started, so there is nothing to conclude yet."
case r.Untokened == 0:
return "Every POS request in this window carried a token. Watch for a few trading days — a quiet window is not the same as an adopted fleet — then set POS_AUTH_REQUIRED=true."
case len(r.Outlets) == 0:
return "Untokened requests are arriving but none names an outlet, so they cannot be traced to a till. Check the paths below before switching enforcement on."
default:
return "Terminals are still calling without a token. The outlets listed below would stop being able to trade the moment POS_AUTH_REQUIRED=true is set. Update those tills first."
}
}
// posAdoptionLogEvery is how often the summary reaches the log.
//
// Long, because this is a slow-moving fact — a fleet adopts over days, not
// minutes — and a log line nobody needs every minute is a log line people learn
// to scroll past.
const posAdoptionLogEvery = 30 * time.Minute
// LogPosAdoption prints the summary on a timer until ctx is done.
//
// In the log as well as on the endpoint because the two get used by different
// people at different moments: somebody already reading Dokploy's log because a
// till is misbehaving should not have to know an endpoint exists.
//
// Outlet ids only, never names or counts of takings — a log is the one place
// this data ends up somewhere nobody chose to put it.
func LogPosAdoption(ctx context.Context) {
ticker := time.NewTicker(posAdoptionLogEvery)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
report := PosAdoptionReport()
if report.Tokened+report.Untokened == 0 {
continue // nothing happened; saying so every half hour is noise
}
if report.Untokened == 0 {
log.Printf("pos auth: %.0f%% of %d requests carried a token; no untokened terminals seen since %s",
report.AdoptedPercent, report.Tokened+report.Untokened, report.Since)
continue
}
outlets := make([]string, 0, len(report.Outlets))
for _, o := range report.Outlets {
outlets = append(outlets, strconv.Itoa(o.Locationid))
}
log.Printf("pos auth: %.0f%% of %d requests carried a token; %d untokened, from outlet(s) %s — these would stop trading if POS_AUTH_REQUIRED were set",
report.AdoptedPercent, report.Tokened+report.Untokened,
report.Untokened, strings.Join(outlets, ", "))
}
}
}