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

163
scratch/dbinspect/main.go Normal file
View File

@@ -0,0 +1,163 @@
// End-to-end proof for the POS ingest, against the live database.
//
// Sets a temporary price on ONE product so a bill can be rung, and prints the
// exact SQL to undo it. Everything else is read-only.
//
// go run ./scratch/dbinspect price # set a test price, print the undo
// go run ./scratch/dbinspect verify # show the bill and the stock it moved
// go run ./scratch/dbinspect restore # put the price back
package main
import (
"fmt"
"log"
"os"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const (
tenantID = 1087
locationID = 1135
productID = 6988 // Mysore Banana — 750 units in stock, 8% tax
testPrice = 60.00
)
func main() {
_ = godotenv.Load()
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Kolkata",
os.Getenv("DB_HOST"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"),
os.Getenv("DB_NAME"), os.Getenv("DB_PORT"),
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatal("connect:", err)
}
mode := "verify"
if len(os.Args) > 1 {
mode = os.Args[1]
}
switch mode {
case "price":
var before float64
db.Raw(`SELECT COALESCE(price,0) FROM productlocations
WHERE tenantid=? AND locationid=? AND productid=?`,
tenantID, locationID, productID).Scan(&before)
if err := db.Exec(`UPDATE productlocations SET price = ?
WHERE tenantid=? AND locationid=? AND productid=?`,
testPrice, tenantID, locationID, productID).Error; err != nil {
log.Fatal("price:", err)
}
fmt.Printf("product %d priced at %.2f (was %.2f)\n", productID, testPrice, before)
fmt.Printf("\nUNDO:\n UPDATE productlocations SET price = %.2f\n"+
" WHERE tenantid=%d AND locationid=%d AND productid=%d;\n",
before, tenantID, locationID, productID)
case "restore":
if err := db.Exec(`UPDATE productlocations SET price = 0
WHERE tenantid=? AND locationid=? AND productid=?`,
tenantID, locationID, productID).Error; err != nil {
log.Fatal("restore:", err)
}
fmt.Printf("product %d price restored to 0\n", productID)
case "verify":
fmt.Println("=== pos_orders ===")
var bills []struct {
Posorderid int
Terminalorderid string
Invoicenumber string
Terminalid string
Cashiername string
Businessdate string
Subtotal float64
Taxamount float64
Roundoff float64
Total float64
Itemcount int
Paymentmode string
}
db.Raw(`SELECT posorderid, terminalorderid, invoicenumber, terminalid,
cashiername, businessdate, subtotal, taxamount, roundoff,
total, itemcount, paymentmode
FROM pos_orders ORDER BY posorderid DESC LIMIT 5`).Scan(&bills)
if len(bills) == 0 {
fmt.Println(" (none yet)")
}
for _, b := range bills {
fmt.Printf(" #%d %s till=%s cashier=%s date=%s\n",
b.Posorderid, b.Invoicenumber, b.Terminalid, b.Cashiername, b.Businessdate)
fmt.Printf(" uuid=%s\n", b.Terminalorderid)
fmt.Printf(" subtotal=%.2f tax=%.2f roundoff=%.2f total=%.2f items=%d paid=%s\n",
b.Subtotal, b.Taxamount, b.Roundoff, b.Total, b.Itemcount, b.Paymentmode)
}
fmt.Println("\n=== pos_order_items ===")
var items []struct {
Posorderid int
Productid int
Productname string
Quantity float64
Unitprice float64
Gstrate float64
Taxamount float64
Linetotal float64
}
db.Raw(`SELECT posorderid, productid, productname, quantity, unitprice,
gstrate, taxamount, linetotal
FROM pos_order_items ORDER BY posorderitemid DESC LIMIT 10`).Scan(&items)
for _, i := range items {
fmt.Printf(" bill#%d %-18.18s qty=%-6.2f @%-8.2f gst=%-6.2f tax=%-7.2f line=%.2f\n",
i.Posorderid, i.Productname, i.Quantity, i.Unitprice,
i.Gstrate, i.Taxamount, i.Linetotal)
}
fmt.Println("\n=== stock ledger for the test product ===")
var moves []struct {
Productstockid int
Quantity int
Stocktype string
Stockdate string
}
db.Raw(`SELECT productstockid, quantity, stocktype,
TO_CHAR(stockdate,'YYYY-MM-DD HH24:MI:SS') AS stockdate
FROM productstocks
WHERE tenantid=? AND locationid=? AND productid=?
ORDER BY productstockid DESC LIMIT 5`,
tenantID, locationID, productID).Scan(&moves)
for _, m := range moves {
fmt.Printf(" #%d %-4s qty=%-6d %s\n",
m.Productstockid, m.Stocktype, m.Quantity, m.Stockdate)
}
var balance float64
db.Raw(`SELECT COALESCE(
SUM(CASE WHEN LOWER(stocktype)='in' THEN quantity ELSE 0 END) -
SUM(CASE WHEN LOWER(stocktype)='out' THEN quantity ELSE 0 END), 0)
FROM productstocks WHERE tenantid=? AND locationid=? AND productid=?`,
tenantID, locationID, productID).Scan(&balance)
fmt.Printf(" balance now: %.0f\n", balance)
case "cleanup":
cleanup(db)
case "customer":
fmt.Println("=== customers matching the uplink probe ===")
showCustomer(db, "9840012345")
default:
log.Fatalf("unknown mode %q — use price, verify, restore or cleanup", mode)
}
}