Files
backend_fiesta/repositories/posPresence.go
Suriya e3459a0f1c Ingest counter sales from the POS terminals, over MQTT and HTTP
A till holds every bill in its own SQLite database and keeps it for
seven days after we acknowledge it, marking one synced only when its id
comes back in an ack. Everything here follows from that.

Silence is not acceptance, so a failing ingest publishes nothing at all
and the terminal simply sends again. A duplicate is a success, because
at-least-once delivery means a lost ack legitimately re-delivers bills
we already hold, and calling those failures would strand a day of
takings on the till. Deduplication is a unique index on the terminal's
UUID plus an advisory lock held for the transaction.

Bills land in pos_orders / pos_order_items rather than orders: a counter
bill carries a cashier, a terminal, a rounding adjustment, promos,
loyalty movement and a payment split that orders has nowhere to put, and
forcing one into the other loses whatever does not fit. Stock is *not*
split — a counter sale writes the same productstocks rows an app order
does, through helpers extracted from createOrderTx so the rule that
prevents overselling has one implementation rather than two.
GetRevenueSummary and GetSalesSummary were extended to union the new
table in; any new report has to remember the same.

Terminal health goes to Redis under a 90-second TTL, sharing the
instance the express backend uses. A heartbeat is a fact with an expiry
date: a till that loses power stops refreshing and ages off the board by
itself, where a Postgres row would need ~288k writes a day and a reaper.

Proven end to end against the live estate before commit: a bill over
HTTP and one over the real Mosquitto broker, the same bill three times
producing one row and one stock movement, and a heartbeat arriving on
the health endpoint. All probe data was removed afterwards.

Four things that only surfaced against real data. An unset jsonb column
failed the very first bill. Product SKUs are unusable as barcodes — 6,245
products share 93 SKUs and "1" covers 5,794 of them — against the till's
unique index, so barcodes fall back to the product id. A taxpercent of
-1 exists and would have put negative GST in a filed slab. And a product
with id 0 exists, which can never be billed and is now skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:48:00 +05:30

196 lines
6.5 KiB
Go

package repositories
import (
"context"
"fmt"
"strconv"
"time"
"nearle/db"
"nearle/models"
"github.com/redis/go-redis/v9"
)
// POS terminal presence, in Redis.
//
// ### Why Redis and not a table
//
// A heartbeat is a fact with an expiry date. Written to Postgres it needs a
// row per till updated twice a minute — around 288,000 writes a day across a
// hundred terminals — and a reaper job to mark a till offline once it stops,
// because a row that says "online" has no way of ageing out on its own.
//
// A Redis key with a TTL does the ageing for free. A till that loses power
// stops refreshing, the key expires, and it disappears from the board without
// anything having to notice. That is the whole design.
//
// ### Keys
//
// pos:terminal:{terminalcode} HASH, TTL 90s — one till's state
// pos:location:{locationid}:terminals SET, no TTL — which tills a shop has
//
// The set has no TTL on purpose, mirroring how `city:{tenantid}:active_deliveries`
// is treated in the express backend: it is an index of what exists, not a claim
// that any of it is alive right now. Membership means "this till has been seen
// here"; liveness is whether the hash still exists.
const (
// Three missed heartbeats. Two would make an ordinary GPRS hiccup look like
// a dead till; five would take two and a half minutes to notice a real one.
posPresenceTTL = 90 * time.Second
posTerminalKeyFmt = "pos:terminal:%s"
posLocationKeyFmt = "pos:location:%s:terminals"
)
type PosPresenceRepository interface {
Record(ctx context.Context, health models.PosHealth) error
Terminal(ctx context.Context, terminalID string) (map[string]string, error)
Location(ctx context.Context, locationID string) ([]map[string]string, error)
}
type posPresenceRepository struct{}
func NewPosPresenceRepository() PosPresenceRepository { return &posPresenceRepository{} }
// Record writes one heartbeat and refreshes its TTL.
func (r *posPresenceRepository) Record(ctx context.Context, health models.PosHealth) error {
if db.Rdb == nil {
return fmt.Errorf("redis is not configured")
}
if health.Terminalid == "" {
return fmt.Errorf("heartbeat has no terminal id")
}
terminalKey := fmt.Sprintf(posTerminalKeyFmt, health.Terminalid)
fields := map[string]any{
"terminal_id": health.Terminalid,
"location_id": health.Locationid,
"store_name": health.Storename,
"app_version": health.Appversion,
"status": health.Status,
"pending_bills": health.Pendingbills,
"pending_registrations": health.Pendingregistrations,
"oldest_pending_at": health.Oldestpendingat,
"today_bills": health.Todaybills,
"today_amount": health.Todayamount,
"last_bill_at": health.Lastbillat,
"reported_at": health.Reportedat,
// Stamped here as well as at the till. The two disagreeing by more than
// a few seconds means the terminal's clock is wrong — which matters,
// because bills are filed under the business date the till decided.
"received_at": time.Now().UTC().Format(time.RFC3339),
}
// Device readings only when the till actually reported them. A build that
// does not collect battery level must not leave one behind saying 0%.
if health.Batterylevel != nil {
fields["battery_level"] = *health.Batterylevel
}
if health.Batterycharging != nil {
fields["battery_charging"] = *health.Batterycharging
}
if health.Storagefreemb != nil {
fields["storage_free_mb"] = *health.Storagefreemb
}
if health.Printerreachable != nil {
fields["printer_reachable"] = *health.Printerreachable
}
if health.Drawerstatus != nil {
fields["drawer_status"] = *health.Drawerstatus
}
// HSet leaves untouched fields in place, so a reading that stops being
// reported would otherwise linger for ever at its last value. Clearing the
// absent ones keeps the hash honest about what this till currently knows.
stale := make([]string, 0, 5)
for field, reported := range map[string]bool{
"battery_level": health.Batterylevel != nil,
"battery_charging": health.Batterycharging != nil,
"storage_free_mb": health.Storagefreemb != nil,
"printer_reachable": health.Printerreachable != nil,
"drawer_status": health.Drawerstatus != nil,
} {
if !reported {
stale = append(stale, field)
}
}
pipe := db.Rdb.TxPipeline()
pipe.HSet(ctx, terminalKey, fields)
if len(stale) > 0 {
pipe.HDel(ctx, terminalKey, stale...)
}
pipe.Expire(ctx, terminalKey, posPresenceTTL)
if health.Locationid != "" {
// No TTL: this is the list of tills a shop has, not a claim that any of
// them is alive. Liveness is whether the hash above still exists.
pipe.SAdd(ctx, fmt.Sprintf(posLocationKeyFmt, health.Locationid), health.Terminalid)
}
_, err := pipe.Exec(ctx)
return err
}
// Terminal returns one till's last known state, or nil if it has gone quiet.
func (r *posPresenceRepository) Terminal(ctx context.Context, terminalID string) (map[string]string, error) {
if db.Rdb == nil {
return nil, fmt.Errorf("redis is not configured")
}
fields, err := db.Rdb.HGetAll(ctx, fmt.Sprintf(posTerminalKeyFmt, terminalID)).Result()
if err != nil && err != redis.Nil {
return nil, err
}
if len(fields) == 0 {
// Expired or never seen. Both mean "not reporting", which is what the
// caller needs to know; distinguishing them would need a durable record
// this deliberately does not keep.
return nil, nil
}
return fields, nil
}
// Location returns every till registered at a shop, live or dark.
//
// A till whose key has expired comes back as a stub with status "offline"
// rather than being omitted. Omitting it would make a dead terminal
// indistinguishable from one that was never installed — and the dead one is
// precisely what somebody is looking for.
func (r *posPresenceRepository) Location(ctx context.Context, locationID string) ([]map[string]string, error) {
if db.Rdb == nil {
return nil, fmt.Errorf("redis is not configured")
}
members, err := db.Rdb.SMembers(ctx, fmt.Sprintf(posLocationKeyFmt, locationID)).Result()
if err != nil && err != redis.Nil {
return nil, err
}
out := make([]map[string]string, 0, len(members))
for _, terminalID := range members {
fields, err := r.Terminal(ctx, terminalID)
if err != nil {
return nil, err
}
if fields == nil {
fields = map[string]string{
"terminal_id": terminalID,
"location_id": locationID,
"status": "offline",
// Says why it is being reported offline, rather than leaving a
// reader to guess whether the till said so or simply vanished.
"reason": "no heartbeat within " + strconv.Itoa(int(posPresenceTTL.Seconds())) + "s",
}
}
out = append(out, fields)
}
return out, nil
}