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>
This commit is contained in:
Suriya
2026-08-03 17:48:00 +05:30
parent 583cd89063
commit e3459a0f1c
23 changed files with 3679 additions and 171 deletions

85
db/redis.go Normal file
View File

@@ -0,0 +1,85 @@
package db
import (
"context"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// Rdb is the shared Redis connection, or nil when Redis is not configured.
//
// Deliberately the *same* instance the express backend uses. POS presence is
// read by the rider app, which talks to that backend, and a second Redis would
// mean either cross-service HTTP calls on every board refresh or two copies of
// the truth about which tills are alive.
//
// Key namespaces do not collide: express owns `delivery:*`, `city:*`,
// `rider_*`; POS owns `pos:*`. Worth keeping that way — a shared datastore only
// stays safe while each writer's keys are obviously its own.
var Rdb *redis.Client
// RedisCtx is the background context for Redis calls made outside a request.
var RedisCtx = context.Background()
// InitRedis connects if REDIS_HOST is set, and does nothing if it is not.
//
// Redis is optional here: without it the POS health board goes dark, but bills
// still arrive and commit. That is the right failure — losing presence is an
// inconvenience, losing a sale is not — so this never aborts startup.
func InitRedis() {
host := strings.TrimSpace(os.Getenv("REDIS_HOST"))
if host == "" {
log.Println("redis: REDIS_HOST not set, POS presence disabled")
return
}
port := getEnv("REDIS_PORT", "6379")
dbIndex, err := strconv.Atoi(getEnv("REDIS_DB", "0"))
if err != nil {
dbIndex = 0
}
Rdb = redis.NewClient(&redis.Options{
Addr: host + ":" + port,
Username: getEnv("REDIS_USER", "default"),
Password: os.Getenv("REDIS_PASSWORD"),
DB: dbIndex,
// Short on purpose. A degraded Redis must fail fast rather than tie up
// a pooled connection for tens of seconds — the express backend learned
// this the hard way, where a 10s x 3-retry config let one stuck call
// hold a connection for ~35s and exhausted the pool under load.
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
PoolTimeout: 4 * time.Second,
})
ctx, cancel := context.WithTimeout(RedisCtx, 5*time.Second)
defer cancel()
if err := Rdb.Ping(ctx).Err(); err != nil {
// Logged, not fatal. A broker that cannot be reached is fatal because
// bills would silently queue; Redis being down only costs the board.
log.Printf("redis: could not reach %s:%s — POS presence will be unavailable: %v", host, port, err)
Rdb = nil
return
}
log.Printf("✅ Redis connected at %s:%s (db %d)", host, port, dbIndex)
}
// CloseRedis releases the pool on shutdown.
func CloseRedis() {
if Rdb == nil {
return
}
if err := Rdb.Close(); err != nil {
log.Printf("redis: close failed: %v", err)
}
}