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>
270 lines
8.5 KiB
Go
270 lines
8.5 KiB
Go
package messaging
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"nearle/models"
|
|
"nearle/services"
|
|
|
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
|
)
|
|
|
|
// Plain-MQTT ingest for the Nearle POS terminals.
|
|
//
|
|
// The sibling of posconsumer.go, and which one you want depends entirely on
|
|
// what is listening on the other end:
|
|
//
|
|
// - **This file** talks MQTT to a broker like Mosquitto or EMQX — the kind
|
|
// already running at the rider app's `66.116.225.226:1883`.
|
|
// - **posconsumer.go** talks the NATS protocol to a NATS server, which
|
|
// exposes MQTT through a gateway but speaks NATS itself on 4222.
|
|
//
|
|
// They are not interchangeable: a NATS client cannot connect to Mosquitto, and
|
|
// an MQTT client cannot use NATS' native subjects. Both call the same
|
|
// PosService, so whichever is running, a bill lands identically.
|
|
//
|
|
// Enabled with MQTT_URL. Both may run at once, which is what a migration
|
|
// between brokers looks like.
|
|
|
|
const (
|
|
// Namespaced under `nearle/` alongside the rider app's
|
|
// `nearle/riders/{riderId}/...`, so one broker ACL rule covers each system
|
|
// and it is obvious from a topic which one it belongs to.
|
|
//
|
|
// Wildcards for MQTT are `+` per level, where NATS uses `*`.
|
|
topicOrders = "nearle/pos/+/+/order"
|
|
topicCustomers = "nearle/pos/+/+/customer"
|
|
topicHealth = "nearle/pos/+/+/health"
|
|
)
|
|
|
|
type PosMqttConsumer struct {
|
|
client mqtt.Client
|
|
svc services.PosService
|
|
}
|
|
|
|
// StartPosMqttConsumer connects and subscribes.
|
|
//
|
|
// Returns (nil, nil) when MQTT_URL is unset — a deployment without a broker is
|
|
// supported, and the caller carries on with the HTTP endpoints.
|
|
func StartPosMqttConsumer(svc services.PosService) (*PosMqttConsumer, error) {
|
|
url := strings.TrimSpace(os.Getenv("MQTT_URL"))
|
|
if url == "" {
|
|
log.Println("pos: MQTT_URL not set, plain-MQTT ingest disabled")
|
|
return nil, nil
|
|
}
|
|
|
|
c := &PosMqttConsumer{svc: svc}
|
|
|
|
opts := mqtt.NewClientOptions().
|
|
AddBroker(url).
|
|
// Stable, so the broker resumes this session and redelivers anything
|
|
// in flight rather than treating every restart as a new subscriber.
|
|
SetClientID(getEnvDefault("MQTT_CLIENT_ID", "nearle-pos-ingest")).
|
|
SetCleanSession(false).
|
|
SetAutoReconnect(true).
|
|
SetMaxReconnectInterval(30 * time.Second).
|
|
SetKeepAlive(30 * time.Second).
|
|
SetConnectionLostHandler(func(_ mqtt.Client, err error) {
|
|
log.Printf("pos: MQTT connection lost: %v", err)
|
|
})
|
|
|
|
if user := os.Getenv("MQTT_USER"); user != "" {
|
|
opts.SetUsername(user).SetPassword(os.Getenv("MQTT_PASSWORD"))
|
|
}
|
|
|
|
// Re-subscribed on every (re)connect rather than once at startup: with a
|
|
// broker that did not persist the session, a reconnect would otherwise come
|
|
// back silently subscribed to nothing.
|
|
opts.SetOnConnectHandler(func(client mqtt.Client) {
|
|
log.Printf("pos: connected to MQTT broker %s", url)
|
|
for topic, handler := range map[string]mqtt.MessageHandler{
|
|
topicOrders: c.handleOrders,
|
|
topicCustomers: c.handleCustomers,
|
|
topicHealth: c.handleHealth,
|
|
} {
|
|
if token := client.Subscribe(topic, 1, handler); token.Wait() && token.Error() != nil {
|
|
log.Printf("pos: could not subscribe to %s: %v", topic, token.Error())
|
|
continue
|
|
}
|
|
log.Printf("pos: subscribed to %s", topic)
|
|
}
|
|
})
|
|
|
|
client := mqtt.NewClient(opts)
|
|
if token := client.Connect(); token.Wait() && token.Error() != nil {
|
|
return nil, fmt.Errorf("could not connect to the MQTT broker at %s: %w", url, token.Error())
|
|
}
|
|
c.client = client
|
|
|
|
return c, nil
|
|
}
|
|
|
|
func (c *PosMqttConsumer) handleOrders(_ mqtt.Client, msg mqtt.Message) {
|
|
var batch models.PosOrderBatch
|
|
if err := json.Unmarshal(msg.Payload(), &batch); err != nil {
|
|
// Dropped rather than retried: there is no batch id to answer with, and
|
|
// the till will time out and re-send anyway.
|
|
log.Printf("pos: discarding unreadable order batch on %s: %v", msg.Topic(), err)
|
|
return
|
|
}
|
|
|
|
store, terminal := topicIdentity(msg.Topic())
|
|
if batch.Storeid == "" {
|
|
batch.Storeid = store
|
|
}
|
|
if batch.Terminalid == "" {
|
|
batch.Terminalid = terminal
|
|
}
|
|
|
|
ack, err := c.svc.IngestOrders(batch)
|
|
if err != nil {
|
|
// Nothing committed, so nothing is acknowledged. The terminal keeps
|
|
// every bill and retries — which is the entire point of the design.
|
|
log.Printf("pos: order batch %s from %s/%s failed, not acking: %v",
|
|
batch.Batchid, store, terminal, err)
|
|
return
|
|
}
|
|
|
|
c.publishAck(store, terminal, ack)
|
|
log.Printf("pos: order batch %s from %s/%s — %d accepted, %d rejected",
|
|
batch.Batchid, store, terminal, len(ack.Accepted), len(ack.Rejected))
|
|
}
|
|
|
|
func (c *PosMqttConsumer) handleCustomers(_ mqtt.Client, msg mqtt.Message) {
|
|
var batch models.PosCustomerBatch
|
|
if err := json.Unmarshal(msg.Payload(), &batch); err != nil {
|
|
log.Printf("pos: discarding unreadable customer batch on %s: %v", msg.Topic(), err)
|
|
return
|
|
}
|
|
|
|
store, terminal := topicIdentity(msg.Topic())
|
|
if batch.Storeid == "" {
|
|
batch.Storeid = store
|
|
}
|
|
if batch.Terminalid == "" {
|
|
batch.Terminalid = terminal
|
|
}
|
|
|
|
ack, err := c.svc.IngestCustomers(batch)
|
|
if err != nil {
|
|
log.Printf("pos: customer batch %s from %s/%s failed, not acking: %v",
|
|
batch.Batchid, store, terminal, err)
|
|
return
|
|
}
|
|
|
|
c.publishAck(store, terminal, ack)
|
|
}
|
|
|
|
// handleHealth records one heartbeat.
|
|
//
|
|
// Never acknowledged. Presence is fire-and-forget: a till whose heartbeat
|
|
// failed must carry on selling, and a blank square on a dashboard is a far
|
|
// better outcome than a terminal that stopped because Redis was busy.
|
|
func (c *PosMqttConsumer) handleHealth(_ mqtt.Client, msg mqtt.Message) {
|
|
var health models.PosHealth
|
|
if err := json.Unmarshal(msg.Payload(), &health); err != nil {
|
|
log.Printf("pos: discarding unreadable heartbeat on %s: %v", msg.Topic(), err)
|
|
return
|
|
}
|
|
|
|
// From the topic, not the body — the same rule bills follow.
|
|
store, terminal := topicIdentity(msg.Topic())
|
|
if health.Locationid == "" {
|
|
health.Locationid = store
|
|
}
|
|
if health.Terminalid == "" {
|
|
health.Terminalid = terminal
|
|
}
|
|
|
|
// The broker's Last Will arrives here too, as a bare {"status":"offline"}
|
|
// with no other fields, which is exactly what should be recorded when a
|
|
// till loses power mid-shift.
|
|
if health.Status == "" {
|
|
health.Status = "online"
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := c.svc.RecordHealth(ctx, health); err != nil {
|
|
log.Printf("pos: could not record heartbeat from %s/%s: %v", store, terminal, err)
|
|
}
|
|
}
|
|
|
|
// publishAck answers the till that sent the batch, and only that till.
|
|
func (c *PosMqttConsumer) publishAck(store, terminal string, ack *models.PosAck) {
|
|
if store == "" || terminal == "" {
|
|
log.Printf("pos: cannot ack batch %s — the topic named no terminal", ack.Batchid)
|
|
return
|
|
}
|
|
|
|
payload, err := json.Marshal(ack)
|
|
if err != nil {
|
|
log.Printf("pos: could not encode ack for batch %s: %v", ack.Batchid, err)
|
|
return
|
|
}
|
|
|
|
topic := fmt.Sprintf("nearle/pos/%s/%s/ack", store, terminal)
|
|
|
|
// QoS 1: losing an ack means the till re-sends bills that are already
|
|
// banked. Harmless, because the ingest deduplicates — but wasted traffic on
|
|
// a shop line that may not have much to spare.
|
|
token := c.client.Publish(topic, 1, false, payload)
|
|
if !token.WaitTimeout(10*time.Second) || token.Error() != nil {
|
|
log.Printf("pos: could not publish ack to %s: %v", topic, token.Error())
|
|
}
|
|
}
|
|
|
|
// topicIdentity reads the store and terminal out of
|
|
// `nearle/pos/<store>/<terminal>/<kind>`.
|
|
//
|
|
// Taken from the topic rather than the body on purpose: a till that could name
|
|
// a store in its payload could post sales into another shop's books.
|
|
func topicIdentity(topic string) (store, terminal string) {
|
|
parts := strings.Split(topic, "/")
|
|
if len(parts) < 5 {
|
|
return "", ""
|
|
}
|
|
return parts[2], parts[3]
|
|
}
|
|
|
|
// PublishCatalogueChanged tells every till in a store to pull now.
|
|
//
|
|
// Retained, so a terminal that was switched off during the change still hears
|
|
// about it when it comes back.
|
|
func (c *PosMqttConsumer) PublishCatalogueChanged(storeID, revision string) error {
|
|
payload, err := json.Marshal(map[string]string{"revision": revision})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token := c.client.Publish(fmt.Sprintf("nearle/pos/%s/catalogue", storeID), 1, true, payload)
|
|
token.Wait()
|
|
return token.Error()
|
|
}
|
|
|
|
// Close disconnects, allowing a moment for in-flight acks to leave.
|
|
func (c *PosMqttConsumer) Close() {
|
|
if c == nil || c.client == nil {
|
|
return
|
|
}
|
|
quiesce, err := strconv.Atoi(getEnvDefault("MQTT_QUIESCE_MS", "2000"))
|
|
if err != nil || quiesce < 0 {
|
|
quiesce = 2000
|
|
}
|
|
c.client.Disconnect(uint(quiesce))
|
|
}
|
|
|
|
func getEnvDefault(key, fallback string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|