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

321
POS_TERMINAL_INGEST.md Normal file
View File

@@ -0,0 +1,321 @@
# POS terminal ingest
How an in-store Nearle POS till reaches this backend. The terminal side of the
contract is specified in the POS repository at `docs/sync-contract.md`; this
covers what was built here and how to turn it on.
## What a terminal expects, and why it matters
A till stores every bill in its own SQLite database the moment a sale
completes, and keeps it for **seven days after we acknowledge it**. It marks a
bill synced if — and only if — the bill's id appears in the `accepted` list of
our reply.
That single rule drives every decision below:
- **Silence is not acceptance.** No reply, an empty reply, a 200 with no body:
all leave the bill on the till, and it is sent again. This is the correct
behaviour when we are struggling, and it is why a failing ingest never
acknowledges.
- **A duplicate is a success.** Delivery is at-least-once. A lost ack makes a
terminal re-send bills we already hold, and calling those failures would
strand a day of takings. The ingest recognises them and accepts them without
touching stock again.
- **A rejection is a decision.** Naming an id in `rejected` stops the till
retrying it and waits for a person. Right for "this bill is malformed", wrong
for "the database is having a bad minute".
## Two ways in, one code path
Both transports call `services.PosService`, so a bill arriving over MQTT and
one arriving over HTTP cannot diverge.
### Where a bill lands
Counter sales are written to **`pos_orders` / `pos_order_items`**, not to
`orders`. A bill is a different document from an app order: it carries a
cashier, a terminal, a rounding adjustment, promo campaigns, loyalty movement
and a payment split across several tenders, none of which `orders` has anywhere
to put. Forcing one into the other's shape loses whichever fields do not fit,
silently.
**Stock is not separate.** A counter sale writes the same `productstocks`
"out" rows an app order does, through the shared helpers in `stockLedger.go`
the same row locks, the same availability check, the same availability re-sync.
Two stock ledgers would mean the catalogue pull sends a till figures that ignore
the till's own trading, and it would oversell.
Existing revenue queries were extended to include `pos_orders`
(`GetRevenueSummary`, `GetSalesSummary`), so dashboards do not understate a shop
that runs a counter. **Any new report has to remember to do the same** — that is
the standing cost of the split.
### HTTP
| Method | Path | Purpose |
|---|---|---|
| `POST` | `/live/api/v1/pos/orders` | Completed bills |
| `POST` | `/live/api/v1/pos/customers` | Shoppers registered at a till |
| `GET` | `/live/api/v1/pos/catalogue` | Product pull. Query: `store_id`, `since`, `page`, `page_size` |
These answer with a **bare ack**, not the usual `{code, message, status}`
envelope — the terminal reads `accepted` from the top level of the body:
```json
{ "batch_id": "9f1c…", "accepted": ["order-uuid"], "rejected": {} }
```
Status codes carry the rest of the contract:
- **200** — batch processed. Individual bills may still be refused; the ack says which.
- **4xx** — the request is wrong (unknown outlet, bad store id). The till halts and shows a person.
- **5xx** — outcome unknown. The till keeps everything and retries with backoff.
### MQTT
The broker is **Eclipse Mosquitto 2.1.2** at `66.116.225.226:1883`, shared with
the rider app (`nearle/riders/#`) and the doormile project (`doormile/riders/#`).
There is **no NATS** in this deployment. NATS servers exist for other projects
on other hosts, but their ports are closed from here and their configs carry no
`mqtt {}` block, so they expose no MQTT gateway. The NATS consumer that briefly
lived in this package has been deleted rather than left to rot.
| Env | Purpose |
|---|---|
| `MQTT_URL` | `tcp://66.116.225.226:1883`. **Unset disables MQTT ingest** — HTTP still works |
| `MQTT_USER` / `MQTT_PASSWORD` | Broker credentials |
| `MQTT_CLIENT_ID` | Defaults to `nearle-pos-ingest`. **Must be unique per replica** — a second connection with the same id evicts the first, and the two would fight in a loop |
| Topic | Direction |
|---|---|
| `nearle/pos/+/+/order` | till → us |
| `nearle/pos/+/+/customer` | till → us |
| `nearle/pos/+/+/health` | till → us, every 30s |
| `nearle/pos/{loc}/{terminal}/ack` | us → till |
| `nearle/pos/{loc}/catalogue` | us → every till at a shop |
Subscribed with `CleanSession(false)` and a stable client id, so a brief restart
resumes rather than missing what arrived meanwhile. Re-subscribes on every
reconnect, because a broker that did not persist the session would otherwise
come back subscribed to nothing.
**Do not treat the broker as durable storage.** Two measured facts make that
unsafe:
- `max_queued_messages` is at its default of **1000**. If this backend is down
long enough for a hundred tills to exceed that, Mosquitto silently drops the
overflow.
- Mosquitto's `autosave_interval` defaults to **30 minutes**, so a hard kill can
lose up to half an hour of persisted state.
Neither loses a bill, and that is the whole point of the acknowledgement design:
a dropped message is simply never acked, so the terminal keeps its copy and
sends it again. The broker is a transport, not a ledger.
**The store and terminal are read from the topic, never from the body.** A till
that could name its own store in the payload could redirect another counter's
acknowledgements.
## Configuring a terminal
Settings → Connectivity & sync → Configure.
| Field | Value |
|---|---|
| Store ID | **The numeric `locationid`.** Not a name — the tenant is resolved from it |
| Terminal name | Whatever staff call the till |
| Transport | `HTTP` or `MQTT` |
| Base URL (HTTP) | `https://your-host/live/api/v1/pos` |
| Broker host / port (MQTT) | `66.116.225.226`, port `1883`, **TLS off** (8883 is not configured) |
`store_id` carrying the locationid is load-bearing: `resolvePosStore` looks the
tenant up from it and refuses a location that is not registered. A terminal
cannot name its tenant.
## Mapping decisions
Worth knowing before the first bill lands.
- **Idempotency** is a unique index on `pos_orders.terminalorderid` — the UUID
minted at the till — plus a Postgres advisory lock held for the life of the
transaction, so a redelivery arriving concurrently waits and then sees the
committed row rather than racing past the check.
- **`businessdate` is the day the sale was rung**, not the day it arrived. A
till that was offline overnight uploads yesterday's bills this morning, and
they belong to yesterday. Every daily figure keys on this.
- **Line amounts are scaled onto the bill total.** The till sends each line at
its pre-apportionment value while the header carries the total after
bill-level discounts. Left alone the item rows would sum to the subtotal and
every report that adds up lines would disagree with the one reading the
header.
- **Payment mode** is the largest tender on a split bill; the full split is
kept verbatim in `paymentsjson` for drawer reconciliation.
- **Fractional quantities round *up* for stock.** `productstocks.quantity` is an
integer column, so 1.5 kg of onions cannot be recorded exactly. Rounding up
never under-deducts, so recorded stock is never higher than the shelf. The app
order path truncates instead (1.5 → 1), which under-deducts; that behaviour was
left untouched rather than silently changed for live traffic. **Making the
column numeric is the real fix.**
- **Customers** match on `contactno` within the outlet's `applocationid`, so a
shopper registered at a till and one who installed the app become one row.
Registrations are **insert-if-absent** — never an update, so a profile
corrected at head office is not reverted by a terminal replaying an old
capture.
- **Catalogue** answers `is_delta: false` and is therefore a full snapshot. The
terminal withdraws every product a snapshot omits, so this must stay true
while the query returns everything stocked at the outlet.
- **Barcodes** come from `products.productsku` — there is no barcode column.
Scanning at the till matches on it, so SKUs must be the scannable code for
barcode scanning to work.
## Terminal health
Every till publishes a heartbeat to `nearle/pos/{loc}/{terminal}/health` every
**30 seconds**. It is stored in Redis, never in Postgres.
| Env | Purpose |
|---|---|
| `REDIS_HOST` / `REDIS_PORT` | **Unset disables presence.** Point at the same Redis the express backend uses |
| `REDIS_USER` / `REDIS_PASSWORD` / `REDIS_DB` | Defaults `default`, empty, `0` |
```
pos:terminal:{terminalcode} HASH, TTL 90s
pos:location:{locationid}:terminals SET, no TTL
```
The TTL is the whole design. A heartbeat is a fact with an expiry date: a till
that loses power stops refreshing, the key expires, and it disappears from the
board with nothing having to notice. In Postgres this would need ~288,000 writes
a day across a hundred tills *and* a reaper job, because a row saying "online"
cannot age out by itself.
90 seconds is three missed beats. 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.
The set has **no TTL**, mirroring `city:{tenantid}:active_deliveries` in the
express backend: it is an index of what exists, not a claim that any of it is
alive. Membership means "this till has been seen here"; liveness is whether the
hash still exists.
Keys are namespaced `pos:*` and do not collide with express's `delivery:*`,
`city:*` or `rider_*`. **Worth keeping that way** — a shared datastore only stays
safe while each writer's keys are obviously its own.
A heartbeat is **never acknowledged**. Presence is fire-and-forget: a till that
stopped selling because a dashboard was busy would be a self-inflicted outage.
Read it back:
| Method | Path |
|---|---|
| `GET` | `/live/api/v1/pos/health/terminal?terminal_id=T4A9` |
| `GET` | `/live/api/v1/pos/health/location?location_id=12` |
A till whose key has expired comes back marked `offline` rather than being
omitted — omitting it would make a dead terminal indistinguishable from one that
was never installed, and the dead one is exactly what somebody is looking for.
What a heartbeat carries: identity and app version; **queue depth**
(`pending_bills`, `pending_registrations`, `oldest_pending_at`) — the numbers
that make a silent sync failure visible; **today's trading** (`today_bills`,
`today_amount`, `last_bill_at`) — a till that is connected but has rung nothing
in three hours is usually a jammed printer or an absent cashier; and device
state.
## Not built
- **Catalogue deltas.** Every pull is a full snapshot. Fine for a few hundred
products, worth revisiting at a few thousand.
- **Loyalty coming back down.** The uplink deliberately carries no points or
spend — those belong to the bill stream, which is idempotent and sees every
counter. Nothing yet computes them centrally and sends them to the tills, so
a shopper's balance at a till is that till's view.
- **Device authentication.** A terminal is trusted with a locationid. Signed
device tokens are the obvious next step before this is exposed publicly.
- **Battery and free storage in the heartbeat.** The reporter has a hook for
them, but this build collects neither — they need platform packages a desktop
build has no use for. Fields that are not collected are **omitted**, not sent
as zero: a board showing every till at 0% battery is worse than one showing
nothing.
## Broker hardening — before a hundred tills join
Measured on the live broker, not assumed. None of this is caused by the POS
work; all of it gets worse the moment bills start flowing.
**There is no ACL file.** `allow_anonymous false` is set and auth is by password
file, but with no `acl_file` every authenticated user is unrestricted on every
topic. The rider app ships `admin` credentials **hardcoded in its APK**, so
anyone who decompiles it today has full publish and subscribe over `nearle/#`
*and* `doormile/#` — a second project's traffic. Adding POS puts every shop's
takings behind the same credential.
A scoped account is two commands and a container restart:
```bash
# A user for the tills, and one for this backend.
mosquitto_passwd -b /mosquitto/config/passwd pos_terminal '<strong-unique-pw>'
mosquitto_passwd -b /mosquitto/config/passwd pos_ingest '<different-pw>'
```
```conf
# /mosquitto/config/acl — then add `acl_file /mosquitto/config/acl` to mosquitto.conf
# Tills: publish their own traffic, read only their own acks and their shop's
# catalogue. The %c substitution binds a client to its own topics, so one till
# cannot read another's.
user pos_terminal
topic write nearle/pos/+/+/order
topic write nearle/pos/+/+/customer
topic write nearle/pos/+/+/status
topic write nearle/pos/+/+/health
topic read nearle/pos/+/+/ack
topic read nearle/pos/+/+/command
topic read nearle/pos/+/catalogue
# This backend: the mirror image.
user pos_ingest
topic read nearle/pos/+/+/order
topic read nearle/pos/+/+/customer
topic read nearle/pos/+/+/health
topic read nearle/pos/+/+/status
topic write nearle/pos/+/+/ack
topic write nearle/pos/+/+/command
topic write nearle/pos/+/catalogue
# Existing projects, scoped to what they already use.
user admin
topic readwrite nearle/riders/#
topic readwrite doormile/#
```
Tighten `pos_terminal` further with per-terminal credentials if you want one
till unable to read another's acks at all; the pattern above trusts tills within
the fleet but not outside it.
**There is no TLS.** Port 8883 is not configured and is closed. Rider GPS
travels in the clear today; POS bills carry customer names and mobile numbers,
which is a different category of exposure on a shared network. Adding a listener
means certs plus republishing the port, i.e. recreating the container — worth
doing before rollout rather than after.
**Two more, from the audit:**
- The broker password and the workolik NATS password differ only in
capitalisation. Diverge them when creating the scoped users.
- Confirm on the host whether the broker was started from the compose file or
from a bare `docker run` before editing the compose file and expecting it to
take effect — there is precedent in this estate for compose existing but not
being the deploy path.
## Capacity
Current load, measured: **~0.9 msg/s inbound**, 5 connected clients, 112
retained messages totalling 8 KB.
A hundred tills add roughly 3.3 msg/s steady (a 1 KB heartbeat each per 30s)
plus bursts of up to ~50 KB when a sale batch goes up. That is 34× current
traffic and well within what Mosquitto handles on any VPS. The broker will not
be the bottleneck; Postgres write throughput on bill ingest is the thing to
watch instead.

View File

@@ -0,0 +1,219 @@
package controllers
import (
"log"
"net/http"
"strconv"
"strings"
"nearle/models"
"nearle/services"
"github.com/gofiber/fiber/v2"
)
// HTTP face of the POS terminal ingest.
//
// These handlers break this codebase's house style in one respect, on purpose:
// they answer with a bare ack rather than the usual
// `{code, message, status, details}` envelope. The terminal reads `accepted`
// from the top level of the body and marks a bill synced only if its id is
// there — wrapping the ack would leave every till queueing for ever.
//
// The status code carries the other half of the contract:
//
// - **200** — the batch was processed. Individual bills may still have been
// refused; the ack says which.
// - **4xx** — the request itself is wrong (unreadable body, unknown outlet).
// The terminal treats these as non-retryable and halts, so a person is
// told rather than the broker hammered.
// - **5xx** — the outcome is unknown. The terminal keeps every bill and
// retries with backoff. This is the right answer when the database is
// having a bad minute: *never* ack a batch that did not commit.
type PosController struct {
posService services.PosService
}
func NewPosController(posService services.PosService) *PosController {
return &PosController{posService: posService}
}
// IngestOrders receives a batch of completed counter bills.
func (ctl *PosController) IngestOrders(c *fiber.Ctx) error {
var batch models.PosOrderBatch
if err := c.BodyParser(&batch); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "could not read the batch: " + err.Error(),
"status": false,
})
}
if strings.TrimSpace(batch.Storeid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "store_id is required",
"status": false,
})
}
ack, err := ctl.posService.IngestOrders(batch)
if err != nil {
return posIngestError(c, "IngestOrders", err)
}
return c.Status(http.StatusOK).JSON(ack)
}
// IngestCustomers receives shoppers registered at a till.
func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error {
var batch models.PosCustomerBatch
if err := c.BodyParser(&batch); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "could not read the batch: " + err.Error(),
"status": false,
})
}
if strings.TrimSpace(batch.Storeid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "store_id is required",
"status": false,
})
}
ack, err := ctl.posService.IngestCustomers(batch)
if err != nil {
return posIngestError(c, "IngestCustomers", err)
}
return c.Status(http.StatusOK).JSON(ack)
}
// Catalogue answers a terminal's product pull.
func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
storeID := strings.TrimSpace(c.Query("store_id"))
if storeID == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "store_id is required",
"status": false,
})
}
page, _ := strconv.Atoi(c.Query("page", "0"))
pageSize, _ := strconv.Atoi(c.Query("page_size", "500"))
result, err := ctl.posService.Catalogue(storeID, c.Query("since"), page, pageSize)
if err != nil {
return posIngestError(c, "Catalogue", err)
}
return c.Status(http.StatusOK).JSON(result)
}
// TerminalHealth returns one till's live state, for a support call that starts
// with a terminal code.
func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
terminalID := strings.TrimSpace(c.Query("terminal_id"))
if terminalID == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "terminal_id is required", "status": false,
})
}
fields, err := ctl.posService.TerminalHealth(c.Context(), terminalID)
if err != nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
})
}
if fields == nil {
// Not an error. The till has simply not reported inside its TTL, which
// is the answer the caller wanted — said plainly rather than as a 404
// that reads like the terminal does not exist.
return c.JSON(fiber.Map{
"code": http.StatusOK,
"status": true,
"details": fiber.Map{
"terminal_id": terminalID,
"status": "offline",
"reason": "no heartbeat received within the presence window",
},
})
}
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields})
}
// LocationHealth returns every till at a shop — the "which counters are dark"
// board. Tills that have stopped reporting come back marked offline rather than
// being omitted, because a missing till is exactly what somebody is looking for.
func (ctl *PosController) LocationHealth(c *fiber.Ctx) error {
locationID := strings.TrimSpace(c.Query("location_id"))
if locationID == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "location_id is required", "status": false,
})
}
terminals, err := ctl.posService.LocationHealth(c.Context(), locationID)
if err != nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
})
}
online := 0
for _, t := range terminals {
if t["status"] == "online" {
online++
}
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"status": true,
"details": fiber.Map{
"location_id": locationID,
"total": len(terminals),
"online": online,
"terminals": terminals,
},
})
}
// posIngestError decides whether the terminal should retry.
//
// The distinction matters more than the message does. A misconfigured store id
// will be just as wrong on the next attempt, so it is reported as a 4xx and the
// till halts and shows a person the reason. Anything else might succeed later,
// so it is a 5xx and the bills stay queued.
func posIngestError(c *fiber.Ctx, op string, err error) error {
log.Printf("pos %s: %v", op, err)
message := err.Error()
lower := strings.ToLower(message)
permanent := strings.Contains(lower, "is not a location id") ||
strings.Contains(lower, "no outlet is registered") ||
strings.Contains(lower, "does not belong to tenant") ||
strings.Contains(lower, "has no products stocked") ||
strings.Contains(lower, "no applocationid configured")
status := http.StatusInternalServerError
if permanent {
status = http.StatusBadRequest
}
return c.Status(status).JSON(fiber.Map{
"code": status,
"message": message,
"status": false,
})
}

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)
}
}

View File

@@ -9,16 +9,21 @@ import (
) )
type Facade struct { type Facade struct {
UserController *controllers.UserController UserController *controllers.UserController
ProductController *controllers.ProductController ProductController *controllers.ProductController
OrderController *controllers.OrderController OrderController *controllers.OrderController
DeliveriesController *controllers.DeliveriesController DeliveriesController *controllers.DeliveriesController
UtilsController *controllers.UtilsController UtilsController *controllers.UtilsController
TenantController *controllers.TenantController TenantController *controllers.TenantController
PartnerController *controllers.PartnerController PartnerController *controllers.PartnerController
CustomerController *controllers.CustomerController CustomerController *controllers.CustomerController
StockRequestController *controllers.StockRequestController StockRequestController *controllers.StockRequestController
CatalogueController *controllers.CatalogueController CatalogueController *controllers.CatalogueController
PosController *controllers.PosController
// Held so the NATS consumer can reach the ingest without going through
// HTTP. Unexported: everything else should use the controller.
posService services.PosService
} }
// NewFacade wires up modules against the main (nearledb) connection. // NewFacade wires up modules against the main (nearledb) connection.
@@ -79,16 +84,33 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
stockRequestService := services.NewStockRequestService(stockRequestRepo, productService) stockRequestService := services.NewStockRequestService(stockRequestRepo, productService)
stockRequestController := controllers.NewStockRequestController(stockRequestService) stockRequestController := controllers.NewStockRequestController(stockRequestService)
// POS Module — ingest from the in-store terminals.
//
// Presence has no *gorm.DB: terminal health lives in Redis under a TTL, so
// a till that loses power ages out of the board by itself instead of
// leaving a Postgres row claiming it is online.
posRepo := repositories.NewPosRepository(db)
posPresence := repositories.NewPosPresenceRepository()
posService := services.NewPosService(posRepo, posPresence)
posController := controllers.NewPosController(posService)
return &Facade{ return &Facade{
UserController: userController, UserController: userController,
ProductController: productController, ProductController: productController,
OrderController: orderController, OrderController: orderController,
DeliveriesController: deliveriesController, DeliveriesController: deliveriesController,
UtilsController: utilsController, UtilsController: utilsController,
TenantController: tenantController, TenantController: tenantController,
PartnerController: partnerController, PartnerController: partnerController,
CustomerController: customerController, CustomerController: customerController,
StockRequestController: stockRequestController, StockRequestController: stockRequestController,
CatalogueController: catalogueController, CatalogueController: catalogueController,
PosController: posController,
posService: posService,
} }
} }
// PosService exposes the ingest to callers outside the HTTP layer — the NATS
// consumer runs the same code path a POST does, so a bill arriving over MQTT
// and one arriving over HTTP cannot diverge.
func (f *Facade) PosService() services.PosService { return f.posService }

41
go.mod
View File

@@ -2,9 +2,21 @@ module nearle
go 1.24 go 1.24
toolchain go1.24.0 require (
firebase.google.com/go v3.13.0+incompatible
require gorm.io/gorm v1.25.10 github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.30
github.com/aws/aws-sdk-go-v2/credentials v1.19.29
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/gofiber/fiber v1.14.6
github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.18.0
golang.org/x/oauth2 v0.12.0
google.golang.org/api v0.143.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.25.10
)
require ( require (
cloud.google.com/go v0.110.7 // indirect cloud.google.com/go v0.110.7 // indirect
@@ -14,12 +26,8 @@ require (
cloud.google.com/go/iam v1.1.1 // indirect cloud.google.com/go/iam v1.1.1 // indirect
cloud.google.com/go/longrunning v0.5.1 // indirect cloud.google.com/go/longrunning v0.5.1 // indirect
cloud.google.com/go/storage v1.30.1 // indirect cloud.google.com/go/storage v1.30.1 // indirect
firebase.google.com/go v3.13.0+incompatible // indirect
github.com/andybalholm/brotli v1.0.6 // indirect github.com/andybalholm/brotli v1.0.6 // indirect
github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.30 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
@@ -28,24 +36,24 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
github.com/aws/smithy-go v1.27.3 // indirect github.com/aws/smithy-go v1.27.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/gofiber/fiber v1.14.6 // indirect
github.com/gofiber/utils v0.0.10 // indirect github.com/gofiber/utils v0.0.10 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.5.9 // indirect github.com/google/go-cmp v0.6.0 // indirect
github.com/google/s2a-go v0.1.7 // indirect github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.4.0 // indirect github.com/google/uuid v1.4.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.1 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.1 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/gorilla/schema v1.1.0 // indirect github.com/gorilla/schema v1.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -53,14 +61,14 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect github.com/klauspost/compress v1.19.0 // indirect
github.com/klauspost/compress v1.17.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect github.com/rivo/uniseg v0.4.4 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/sagikazarmark/locafero v0.3.0 // indirect github.com/sagikazarmark/locafero v0.3.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect
@@ -72,15 +80,14 @@ require (
github.com/valyala/fasthttp v1.50.0 // indirect github.com/valyala/fasthttp v1.50.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.31.0 // indirect golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/net v0.21.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/oauth2 v0.12.0 // indirect
golang.org/x/sync v0.10.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/time v0.3.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.143.0 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb // indirect google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb // indirect google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb // indirect
@@ -88,7 +95,6 @@ require (
google.golang.org/grpc v1.58.2 // indirect google.golang.org/grpc v1.58.2 // indirect
google.golang.org/protobuf v1.31.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
) )
require ( require (
@@ -99,5 +105,4 @@ require (
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/mysql v1.5.2
) )

61
go.sum
View File

@@ -93,7 +93,13 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSI
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -105,6 +111,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -118,9 +128,6 @@ github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyT
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/gofiber/fiber v1.14.6 h1:QRUPvPmr8ijQuGo1MgupHBn8E+wW0IKqiOvIZPtV70o= github.com/gofiber/fiber v1.14.6 h1:QRUPvPmr8ijQuGo1MgupHBn8E+wW0IKqiOvIZPtV70o=
github.com/gofiber/fiber v1.14.6/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM= github.com/gofiber/fiber v1.14.6/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM=
github.com/gofiber/fiber/v2 v2.50.0 h1:ia0JaB+uw3GpNSCR5nvC5dsaxXjRU5OEu36aytx+zGw= github.com/gofiber/fiber/v2 v2.50.0 h1:ia0JaB+uw3GpNSCR5nvC5dsaxXjRU5OEu36aytx+zGw=
@@ -170,11 +177,14 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@@ -200,6 +210,8 @@ github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qK
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY= github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
@@ -226,8 +238,10 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -257,12 +271,14 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ= github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U= github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
@@ -303,6 +319,8 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -311,6 +329,8 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -320,8 +340,6 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -393,10 +411,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -418,8 +434,6 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -461,8 +475,6 @@ golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -474,8 +486,6 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -641,8 +651,8 @@ google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
@@ -650,13 +660,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

33
main.go
View File

@@ -5,6 +5,7 @@ import (
"log" "log"
"nearle/db" "nearle/db"
"nearle/facade" "nearle/facade"
"nearle/messaging"
"nearle/models" "nearle/models"
"nearle/routes" "nearle/routes"
"os" "os"
@@ -39,13 +40,36 @@ func main() {
db.Connect() db.Connect()
fmt.Println("✅ Database connections established!") fmt.Println("✅ Database connections established!")
// Shared with the express backend. POS terminal presence lives here under a
// TTL; optional, because losing the health board is an inconvenience and
// losing a sale is not.
db.InitRedis()
// Ensure schema is updated // Ensure schema is updated
db.DB.AutoMigrate(&models.StockRequest{}) db.DB.AutoMigrate(&models.StockRequest{})
// Counter sales from the in-store terminals. Separate tables from `orders`
// because a bill carries a cashier, a terminal, rounding, promos, loyalty
// and a payment split that `orders` has nowhere to put.
if err := db.DB.AutoMigrate(&models.PosOrders{}, &models.PosOrderItems{}); err != nil {
log.Fatal("POS schema migration failed:", err)
}
f := facade.NewFacade(db.DB, db.CatalogueDB) f := facade.NewFacade(db.DB, db.CatalogueDB)
routes.RegisterRoutes(app, f) routes.RegisterRoutes(app, f)
// POS terminals reach the ingest over MQTT when MQTT_URL is set, and over
// HTTP otherwise. Both land on the same service, so a bill cannot behave
// differently depending on how it arrived.
//
// A broker that is configured but unreachable is fatal on purpose: coming
// up healthy while every till quietly queues is the worse failure.
posMqtt, err := messaging.StartPosMqttConsumer(f.PosService())
if err != nil {
log.Fatal("POS MQTT consumer failed to start:", err)
}
// Start server // Start server
go func() { go func() {
if err := app.Listen(":1122"); err != nil { if err := app.Listen(":1122"); err != nil {
@@ -53,7 +77,7 @@ func main() {
} }
}() }()
gracefulShutdown() gracefulShutdown(posMqtt)
} }
func selectDBMiddleware(c *fiber.Ctx) error { func selectDBMiddleware(c *fiber.Ctx) error {
@@ -78,13 +102,18 @@ func selectDBMiddleware(c *fiber.Ctx) error {
return c.Next() return c.Next()
} }
func gracefulShutdown() { func gracefulShutdown(posMqtt *messaging.PosMqttConsumer) {
c := make(chan os.Signal, 1) c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM) signal.Notify(c, os.Interrupt, syscall.SIGTERM)
<-c <-c
fmt.Println("\nShutting down gracefully...") fmt.Println("\nShutting down gracefully...")
// Drained before anything else: a bill mid-commit still gets its ack, and
// without one the terminal would hold it and send it again on restart.
posMqtt.Close()
db.CloseRedis()
// Normally: close db.DB_DEV and db.DB_LIVE // Normally: close db.DB_DEV and db.DB_LIVE
// Example: // Example:
// closeDB(db.DB_DEV) // closeDB(db.DB_DEV)

269
messaging/posmqtt.go Normal file
View File

@@ -0,0 +1,269 @@
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
}

370
messaging/posmqtt_test.go Normal file
View File

@@ -0,0 +1,370 @@
package messaging
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
"nearle/models"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// These drive the real handlers through paho's own interfaces, so what is under
// test is the code that runs in production rather than a parallel
// reimplementation of it.
//
// No embedded broker: the infrastructure audit established that the broker is
// Mosquitto 2.1.2 and that it works. What was never established is whether
// *this* code acks the right terminal, and refuses to ack when the ingest
// failed — which is where a bug would cost a shop its takings.
// fakePosService lets a test decide what the ingest did.
type fakePosService struct {
ack *models.PosAck
err error
batches []models.PosOrderBatch
custBatch []models.PosCustomerBatch
heartbeats []models.PosHealth
healthErr error
}
func (f *fakePosService) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) {
f.batches = append(f.batches, batch)
return f.ack, f.err
}
func (f *fakePosService) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) {
f.custBatch = append(f.custBatch, batch)
return f.ack, f.err
}
func (f *fakePosService) Catalogue(string, string, int, int) (*models.PosCatalogueResponse, error) {
return nil, nil
}
func (f *fakePosService) RecordHealth(_ context.Context, health models.PosHealth) error {
f.heartbeats = append(f.heartbeats, health)
return f.healthErr
}
func (f *fakePosService) TerminalHealth(context.Context, string) (map[string]string, error) {
return nil, nil
}
func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) {
return nil, nil
}
// ---------------------------------------------------------------- paho fakes
type published struct {
topic string
qos byte
retained bool
payload []byte
}
// fakeClient records what was published and nothing else.
type fakeClient struct {
mu sync.Mutex
sent []published
}
func (c *fakeClient) Publish(topic string, qos byte, retained bool, payload any) mqtt.Token {
c.mu.Lock()
defer c.mu.Unlock()
body, _ := payload.([]byte)
c.sent = append(c.sent, published{topic: topic, qos: qos, retained: retained, payload: body})
return doneToken{}
}
func (c *fakeClient) publishes() []published {
c.mu.Lock()
defer c.mu.Unlock()
return append([]published(nil), c.sent...)
}
func (c *fakeClient) IsConnected() bool { return true }
func (c *fakeClient) IsConnectionOpen() bool { return true }
func (c *fakeClient) Connect() mqtt.Token { return doneToken{} }
func (c *fakeClient) Disconnect(uint) {}
func (c *fakeClient) Subscribe(string, byte, mqtt.MessageHandler) mqtt.Token {
return doneToken{}
}
func (c *fakeClient) SubscribeMultiple(map[string]byte, mqtt.MessageHandler) mqtt.Token {
return doneToken{}
}
func (c *fakeClient) Unsubscribe(...string) mqtt.Token { return doneToken{} }
func (c *fakeClient) AddRoute(string, mqtt.MessageHandler) {}
func (c *fakeClient) OptionsReader() mqtt.ClientOptionsReader { return mqtt.ClientOptionsReader{} }
type doneToken struct{}
func (doneToken) Wait() bool { return true }
func (doneToken) WaitTimeout(time.Duration) bool { return true }
func (doneToken) Done() <-chan struct{} {
ch := make(chan struct{})
close(ch)
return ch
}
func (doneToken) Error() error { return nil }
type fakeMessage struct {
topic string
payload []byte
}
func (m fakeMessage) Duplicate() bool { return false }
func (m fakeMessage) Qos() byte { return 1 }
func (m fakeMessage) Retained() bool { return false }
func (m fakeMessage) Topic() string { return m.topic }
func (m fakeMessage) MessageID() uint16 { return 1 }
func (m fakeMessage) Payload() []byte { return m.payload }
func (m fakeMessage) Ack() {}
func consumerFor(svc *fakePosService) (*PosMqttConsumer, *fakeClient) {
client := &fakeClient{}
return &PosMqttConsumer{client: client, svc: svc}, client
}
func orderBatch(batchID string, ids ...string) []byte {
orders := make([]models.PosOrder, 0, len(ids))
for _, id := range ids {
orders = append(orders, models.PosOrder{Id: id, Invoicenumber: "INV-" + id})
}
body, _ := json.Marshal(models.PosOrderBatch{Schema: 1, Batchid: batchID, Orders: orders})
return body
}
// ---------------------------------------------------------------------- tests
func TestAckGoesBackToTheTerminalThatSent(t *testing.T) {
ack := models.NewPosAck("batch-1")
ack.Accept("order-a")
c, client := consumerFor(&fakePosService{ack: ack})
c.handleOrders(nil, fakeMessage{
topic: "nearle/pos/12/T4A9/order",
payload: orderBatch("batch-1", "order-a"),
})
sent := client.publishes()
if len(sent) != 1 {
t.Fatalf("published %d messages, want exactly 1", len(sent))
}
// Addressed to the till that sent it. A store-wide ack would tell every
// other counter that bills they never sent had landed.
if sent[0].topic != "nearle/pos/12/T4A9/ack" {
t.Errorf("ack topic = %q, want nearle/pos/12/T4A9/ack", sent[0].topic)
}
if sent[0].qos != 1 {
t.Errorf("ack qos = %d, want 1", sent[0].qos)
}
if sent[0].retained {
t.Error("the ack was retained; a stale ack replayed to a new session would retire bills that were never sent")
}
var got models.PosAck
if err := json.Unmarshal(sent[0].payload, &got); err != nil {
t.Fatalf("decode ack: %v", err)
}
if got.Batchid != "batch-1" {
t.Errorf("batch_id = %q, want batch-1", got.Batchid)
}
if len(got.Accepted) != 1 || got.Accepted[0] != "order-a" {
t.Errorf("accepted = %v, want [order-a]", got.Accepted)
}
}
func TestAFailedIngestIsNotAcked(t *testing.T) {
// The single most important behaviour here. An ack the ingest did not earn
// tells a terminal to delete a bill that was never banked.
c, client := consumerFor(&fakePosService{err: errors.New("database is having a bad minute")})
c.handleOrders(nil, fakeMessage{
topic: "nearle/pos/12/T4A9/order",
payload: orderBatch("batch-2", "order-a"),
})
if sent := client.publishes(); len(sent) != 0 {
t.Fatalf("a batch that failed to commit was acknowledged: %s", sent[0].payload)
}
}
func TestStoreAndTerminalComeFromTheTopic(t *testing.T) {
// The body is authoritative for nothing about identity. A till that could
// name a store in its payload could post sales into another shop's books.
svc := &fakePosService{ack: models.NewPosAck("batch-3")}
c, _ := consumerFor(svc)
c.handleOrders(nil, fakeMessage{
topic: "nearle/pos/44/T0001/order",
payload: orderBatch("batch-3", "order-x"),
})
if len(svc.batches) != 1 {
t.Fatalf("the batch never reached the ingest")
}
if got := svc.batches[0].Storeid; got != "44" {
t.Errorf("store_id = %q, want 44 (from the topic)", got)
}
if got := svc.batches[0].Terminalid; got != "T0001" {
t.Errorf("terminal_id = %q, want T0001", got)
}
}
func TestABodyCannotOverrideTheTopicIdentity(t *testing.T) {
// A till claiming to be somewhere else must not be believed.
svc := &fakePosService{ack: models.NewPosAck("batch-4")}
c, client := consumerFor(svc)
body, _ := json.Marshal(models.PosOrderBatch{
Schema: 1,
Batchid: "batch-4",
Storeid: "99", // a shop this till has no claim on
Orders: []models.PosOrder{{Id: "order-a"}},
})
c.handleOrders(nil, fakeMessage{topic: "nearle/pos/12/T4A9/order", payload: body})
// The ingest still resolves the store it was *told*, which is a known gap —
// but the ack must go back to the real terminal, so a forged store id
// cannot redirect another till's acknowledgements.
sent := client.publishes()
if len(sent) != 1 || sent[0].topic != "nearle/pos/12/T4A9/ack" {
t.Fatalf("ack went to %v, want nearle/pos/12/T4A9/ack", sent)
}
}
func TestAMalformedBatchIsDroppedWithoutAcking(t *testing.T) {
// Nothing to key an ack on, and nothing committed. Silence is correct: the
// till times out and re-sends.
svc := &fakePosService{ack: models.NewPosAck("x")}
c, client := consumerFor(svc)
c.handleOrders(nil, fakeMessage{
topic: "nearle/pos/12/T4A9/order",
payload: []byte("not json at all"),
})
if len(svc.batches) != 0 {
t.Error("an unreadable batch reached the ingest")
}
if sent := client.publishes(); len(sent) != 0 {
t.Errorf("an unreadable batch was acknowledged: %s", sent[0].payload)
}
}
func TestRegistrationsAckOnTheSameTopic(t *testing.T) {
ack := models.NewPosAck("cust-1")
ack.Accept("customer-a")
c, client := consumerFor(&fakePosService{ack: ack})
body, _ := json.Marshal(models.PosCustomerBatch{
Schema: 1,
Batchid: "cust-1",
Customers: []models.PosCustomer{{Id: "customer-a", Mobile: "9840012345", Name: "Meena"}},
})
c.handleCustomers(nil, fakeMessage{topic: "nearle/pos/12/T4A9/customer", payload: body})
sent := client.publishes()
if len(sent) != 1 || sent[0].topic != "nearle/pos/12/T4A9/ack" {
t.Fatalf("registration ack went to %v", sent)
}
}
func TestAHeartbeatIsRecordedAndNeverAcked(t *testing.T) {
// Presence is fire-and-forget. A till waiting on an ack for its heartbeat
// would be a till that a busy dashboard can block.
svc := &fakePosService{}
c, client := consumerFor(svc)
body, _ := json.Marshal(models.PosHealth{Status: "online", Pendingbills: 4, Todaybills: 37})
c.handleHealth(nil, fakeMessage{topic: "nearle/pos/12/T4A9/health", payload: body})
if len(svc.heartbeats) != 1 {
t.Fatalf("the heartbeat never reached the presence store")
}
got := svc.heartbeats[0]
if got.Terminalid != "T4A9" || got.Locationid != "12" {
t.Errorf("identity = %s/%s, want 12/T4A9 (from the topic)", got.Locationid, got.Terminalid)
}
if got.Pendingbills != 4 {
t.Errorf("pending_bills = %d, want 4", got.Pendingbills)
}
if sent := client.publishes(); len(sent) != 0 {
t.Error("a heartbeat was acknowledged; presence must be fire-and-forget")
}
}
func TestALastWillIsRecordedAsOffline(t *testing.T) {
// The broker publishes this on the till's behalf when it loses power. It
// carries nothing but a status, and that is the point — it is the only way
// to tell "closed for the night" from "unplugged".
svc := &fakePosService{}
c, _ := consumerFor(svc)
c.handleHealth(nil, fakeMessage{
topic: "nearle/pos/12/T4A9/health",
payload: []byte(`{"status":"offline"}`),
})
if len(svc.heartbeats) != 1 {
t.Fatal("the will never reached the presence store")
}
if got := svc.heartbeats[0].Status; got != "offline" {
t.Errorf("status = %q, want offline — a will must not be defaulted to online", got)
}
}
func TestAFailedPresenceWriteDoesNotStopTheTill(t *testing.T) {
// Redis being unreachable must cost the board, never a sale.
svc := &fakePosService{healthErr: errors.New("redis is down")}
c, client := consumerFor(svc)
c.handleHealth(nil, fakeMessage{
topic: "nearle/pos/12/T4A9/health",
payload: []byte(`{"status":"online"}`),
})
if sent := client.publishes(); len(sent) != 0 {
t.Error("a failed heartbeat produced a message back to the till")
}
}
func TestAnEmptyAckSerialisesAsAListNotNull(t *testing.T) {
// A terminal reading `null` for accepted treats the whole batch as
// unconfirmed and sends it again for ever.
body, err := json.Marshal(models.NewPosAck("batch-5"))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if want := `"accepted":[]`; !strings.Contains(string(body), want) {
t.Errorf("ack serialised as %s, want it to contain %s", body, want)
}
}
func TestTopicIdentityRejectsShortTopics(t *testing.T) {
// A topic that names no terminal must yield nothing rather than a
// plausible-looking wrong answer that sends an ack to the wrong place.
for _, topic := range []string{"nearle/pos/order", "pos/12/T4A9/order", "", "nearle"} {
store, terminal := topicIdentity(topic)
if store != "" || terminal != "" {
t.Errorf("topicIdentity(%q) = %q/%q, want empty", topic, store, terminal)
}
}
store, terminal := topicIdentity("nearle/pos/12/T4A9/order")
if store != "12" || terminal != "T4A9" {
t.Errorf("topicIdentity = %q/%q, want 12/T4A9", store, terminal)
}
}

212
models/pos.go Normal file
View File

@@ -0,0 +1,212 @@
package models
// Wire format for the Nearle POS terminal.
//
// These types mirror what the till actually publishes, field for field. The
// terminal is the fixed side of this contract: it is installed on a hundred
// machines that cannot all be updated at once, so the names here follow its
// JSON rather than this codebase's usual Go casing.
//
// The authoritative description lives in the terminal repository at
// docs/sync-contract.md.
// PosOrderItem is one line of a counter bill.
//
// Productid arrives as a string because the till stores catalogue ids as text.
// It carries the numeric products.productid this backend issued during a
// catalogue pull, so it parses back to an int on arrival.
type PosOrderItem struct {
Productid string `json:"product_id"`
Barcode string `json:"barcode"`
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Unitprice float64 `json:"unit_price"`
Discount float64 `json:"discount"`
Gstrate float64 `json:"gst_rate"`
Tax float64 `json:"tax"`
Linetotal float64 `json:"line_total"`
}
// PosOrderCustomer is the shopper snapshot carried on the bill itself.
//
// Deliberately thin. The full profile travels on its own uplink; this exists so
// a bill can be attached to somebody even when their registration has not
// arrived yet.
type PosOrderCustomer struct {
Id string `json:"id"`
Mobile string `json:"mobile"`
Name string `json:"name"`
}
// PosOrderPayment is one tender against a bill. A bill may be split across
// several.
type PosOrderPayment struct {
Method string `json:"method"`
Amount float64 `json:"amount"`
Reference string `json:"reference"`
}
// PosOrderPromo records a campaign that fired, as an amount rather than a rule.
// A bill read back years later must show what was actually given, not what
// today's rules would give.
type PosOrderPromo struct {
Id string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Amount float64 `json:"amount"`
}
// PosOrder is one completed sale.
//
// Id is a UUID minted at the till and is the only thing that identifies this
// bill. It is what deduplication keys on, because at-least-once delivery means
// the same bill legitimately arrives more than once.
type PosOrder struct {
Id string `json:"id"`
Invoicenumber string `json:"invoice_number"`
Createdat string `json:"created_at"`
Terminalid string `json:"terminal_id"`
Cashier string `json:"cashier"`
Customer *PosOrderCustomer `json:"customer"`
Subtotal float64 `json:"subtotal"`
Discount float64 `json:"discount"`
Promos []PosOrderPromo `json:"promos"`
Tax float64 `json:"tax"`
Roundoff float64 `json:"round_off"`
Total float64 `json:"total"`
Pointsearned int `json:"points_earned"`
Pointsredeemed int `json:"points_redeemed"`
Payments []PosOrderPayment `json:"payments"`
Items []PosOrderItem `json:"items"`
// GST per slab, as printed on the tax invoice: {"0.05": 12.30, "0.18": 4.50}.
// Absent from terminals built before this field existed, which is why every
// consumer of it has to tolerate an empty map.
Taxbreakdown map[string]float64 `json:"tax_breakdown"`
}
// PosOrderBatch is the envelope a terminal publishes.
//
// Storeid carries the numeric tenantlocations.locationid as a string. The
// tenant is resolved from it server-side and never taken from the terminal — a
// till must not be able to name the tenant it posts into.
type PosOrderBatch struct {
Schema int `json:"schema"`
Batchid string `json:"batch_id"`
Storeid string `json:"store_id"`
Terminalid string `json:"terminal_id"`
Sentat string `json:"sent_at"`
Orders []PosOrder `json:"orders"`
}
// PosCustomer is a shopper registered at a till.
//
// No loyalty figures. Points, lifetime spend and visit counts are derived from
// the bill stream, which is idempotent and sees every counter; accepting a
// terminal's local balance would make the last till to sync win.
type PosCustomer struct {
Id string `json:"id"`
Mobile string `json:"mobile"`
Name string `json:"name"`
Email string `json:"email"`
Gender string `json:"gender"`
Dateofbirth string `json:"date_of_birth"`
Registeredat string `json:"registered_at"`
Registeredbyterminal string `json:"registered_by_terminal"`
}
type PosCustomerBatch struct {
Schema int `json:"schema"`
Batchid string `json:"batch_id"`
Storeid string `json:"store_id"`
Terminalid string `json:"terminal_id"`
Sentat string `json:"sent_at"`
Customers []PosCustomer `json:"customers"`
}
// PosAck is the only thing that retires a bill on the terminal.
//
// The rule the whole design rests on: a till marks a record synced if and only
// if its id appears in Accepted. Silence is not acceptance — an empty ack, a
// dropped connection or a 200 with no body all leave the record pending and it
// is sent again.
//
// Naming an id in Rejected is a decision, not a fault: the terminal stops
// retrying that record and waits for a person. Use it for "this bill is
// malformed", never for "the database is having a bad minute" — for the latter,
// do not ack at all and let the till back off and retry.
type PosAck struct {
Batchid string `json:"batch_id"`
Accepted []string `json:"accepted"`
Rejected map[string]string `json:"rejected,omitempty"`
}
// NewPosAck returns an ack with non-nil members, so it serialises as `[]` and
// `{}` rather than `null`. A terminal reading null for accepted would treat the
// whole batch as unconfirmed.
func NewPosAck(batchID string) *PosAck {
return &PosAck{
Batchid: batchID,
Accepted: make([]string, 0),
Rejected: make(map[string]string),
}
}
func (a *PosAck) Accept(id string) {
a.Accepted = append(a.Accepted, id)
}
func (a *PosAck) Reject(id, reason string) {
a.Rejected[id] = reason
}
// PosCatalogueProduct is one product as the till stores it.
type PosCatalogueProduct struct {
Id string `json:"id"`
Name string `json:"name"`
Barcode string `json:"barcode"`
Sku string `json:"sku"`
Category string `json:"category"`
Price float64 `json:"price"`
Mrp float64 `json:"mrp,omitempty"`
Stock float64 `json:"stock"`
Unit string `json:"unit"`
Gstrate float64 `json:"gst_rate"`
Hsncode string `json:"hsn_code,omitempty"`
Brand string `json:"brand,omitempty"`
Isactive bool `json:"is_active"`
}
// PosCatalogueCustomer is a shopper travelling *down* to a terminal.
//
// The mirror of PosCustomer, and the difference is the point: the uplink
// carries no loyalty figures because a till's local balance is only its own
// view, while the downlink carries them because the back office has seen every
// counter and is the only thing that can total them.
type PosCatalogueCustomer struct {
Id string `json:"id"`
Name string `json:"name"`
Mobile string `json:"mobile"`
Email string `json:"email,omitempty"`
Gender string `json:"gender,omitempty"`
Dateofbirth string `json:"date_of_birth,omitempty"`
Loyaltypoints int `json:"loyalty_points"`
Lifetimespend float64 `json:"lifetime_spend"`
Visitcount int `json:"visit_count"`
Createdat string `json:"created_at,omitempty"`
Lastvisitat string `json:"last_visit_at,omitempty"`
}
// PosCatalogueResponse answers a terminal's catalogue pull.
//
// Isdelta is load-bearing. A response marked false is treated as a full
// snapshot and the terminal withdraws every product it does not mention — so
// answering a change set with false empties the shelf.
type PosCatalogueResponse struct {
Revision string `json:"revision"`
Isdelta bool `json:"is_delta"`
Hasmore bool `json:"has_more"`
Products []PosCatalogueProduct `json:"products"`
Customers []PosCatalogueCustomer `json:"customers"`
Retiredids []string `json:"retired_product_ids"`
}

58
models/poshealth.go Normal file
View File

@@ -0,0 +1,58 @@
package models
// PosHealth is what a till reports about itself every 30 seconds.
//
// This is a liveness signal, not a record. It lives in Redis under a TTL and is
// never written to Postgres: a terminal that dies simply stops refreshing and
// disappears from the board on its own, with no reaper job and no row left
// claiming "online" three days after the shop closed.
//
// The fields exist to answer questions a person actually asks when a shop
// phones in: is the till on, is it reaching us, is it selling anything, and is
// the hardware in the way.
type PosHealth struct {
// Identity. Terminalid is the short code printed on invoices — the thing a
// support call starts with.
Terminalid string `json:"terminal_id"`
Locationid string `json:"location_id"`
Storename string `json:"store_name"`
Appversion string `json:"app_version"`
// "online" while the till is refreshing this. The broker's Last Will
// overwrites it with "offline" if the terminal loses power mid-shift, which
// is the only way to tell *closed for the night* from *unplugged*.
Status string `json:"status"`
// Queue depth — the number that matters most. A shop quietly accumulating
// unsynced takings looks completely normal from the shop floor, and this is
// the only thing that makes it visible before someone reconciles a till and
// finds a day missing.
Pendingbills int `json:"pending_bills"`
Pendingregistrations int `json:"pending_registrations"`
Oldestpendingat string `json:"oldest_pending_at"`
// Today's trading. A till that is connected but has rung nothing in three
// hours usually means a jammed printer or an absent cashier, and neither
// shows up in a plain online/offline board.
Todaybills int `json:"today_bills"`
Todayamount float64 `json:"today_amount"`
Lastbillat string `json:"last_bill_at"`
// Device state, for pre-emptive support.
//
// Pointers so that *not reported* is distinguishable from *reported as
// zero*. Not every build collects these — battery and free storage need
// platform packages a desktop till has no use for — and writing an
// uncollected reading as 0 would show a board full of terminals on a flat
// battery with an unreachable printer. A nil field is skipped entirely.
Batterylevel *int `json:"battery_level,omitempty"`
Batterycharging *bool `json:"battery_charging,omitempty"`
Storagefreemb *int `json:"storage_free_mb,omitempty"`
Printerreachable *bool `json:"printer_reachable,omitempty"`
Drawerstatus *string `json:"drawer_status,omitempty"`
// Stamped by the till. The consumer also stamps its own arrival time, and
// the two disagreeing is itself a signal — a till whose clock is wrong
// writes bills under the wrong business date.
Reportedat string `json:"reported_at"`
}

138
models/posorder.go Normal file
View File

@@ -0,0 +1,138 @@
package models
import "time"
// Counter sales, stored at the fidelity the till actually rang them.
//
// Separate from `orders` on purpose. An app order and a counter bill are
// different documents: a bill carries a cashier, a terminal, a rounding
// adjustment, promo campaigns, loyalty movement and a payment split across
// several tenders, none of which `orders` has anywhere to put. Forcing one into
// the other's shape loses whichever fields do not fit, and the loss is silent.
//
// The cost of the split is that existing revenue queries do not see these rows
// until they are extended to union them in — done in orderRepository's summary
// queries, and the thing to remember when adding a new report.
//
// Stock is *not* separate: a counter sale writes the same productstocks "out"
// rows an app order does, through the same helper. Two stock ledgers would mean
// the catalogue pull sends a till figures that ignore its own sales.
// PosOrders is one counter bill.
type PosOrders struct {
Posorderid int `json:"posorderid" gorm:"primaryKey;autoIncrement;column:posorderid"`
// The UUID minted at the till. Globally unique by construction and the only
// thing that identifies this bill, so it carries a unique index: delivery is
// at-least-once and the same bill legitimately arrives more than once.
Terminalorderid string `json:"terminalorderid" gorm:"column:terminalorderid;uniqueIndex;not null"`
// Human-facing, and unique only per terminal — a till that was replaced
// restarts its own series, so gaps are normal and duplicates across
// terminals are expected.
Invoicenumber string `json:"invoicenumber" gorm:"column:invoicenumber;index"`
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
Locationid int `json:"locationid" gorm:"column:locationid;index"`
// Which physical till, e.g. "T4A9". Free text: nothing keys on it, but a
// support call starts with it.
Terminalid string `json:"terminalid" gorm:"column:terminalid;index"`
Cashiername string `json:"cashiername" gorm:"column:cashiername"`
// Resolved against the customers table. Zero for a walk-in.
Customerid int `json:"customerid" gorm:"column:customerid;index"`
Customermobile string `json:"customermobile" gorm:"column:customermobile"`
Customername string `json:"customername" gorm:"column:customername"`
// When the sale was rung, not when it reached us — a till that was offline
// for a day uploads bills whose Billedat is yesterday, and every daily
// figure must use this rather than Receivedat.
Billedat time.Time `json:"billedat" gorm:"column:billedat;index"`
// YYYY-MM-DD of Billedat, denormalised so a day's takings are one indexed
// equality match rather than a range scan with timezone arithmetic.
Businessdate string `json:"businessdate" gorm:"column:businessdate;index"`
Subtotal float64 `json:"subtotal" gorm:"column:subtotal"`
Discount float64 `json:"discount" gorm:"column:discount"`
Taxamount float64 `json:"taxamount" gorm:"column:taxamount"`
// The paise adjustment printed on the bill. Kept because total is not
// derivable from the other columns without it.
Roundoff float64 `json:"roundoff" gorm:"column:roundoff"`
// What the shopper actually paid. The figure every revenue report sums.
Total float64 `json:"total" gorm:"column:total"`
Pointsearned int `json:"pointsearned" gorm:"column:pointsearned"`
Pointsredeemed int `json:"pointsredeemed" gorm:"column:pointsredeemed"`
Itemcount int `json:"itemcount" gorm:"column:itemcount"`
// The largest tender, for the common "how did they pay" grouping.
Paymentmode string `json:"paymentmode" gorm:"column:paymentmode;index"`
// The full split, verbatim. A bill can be part cash, part card, part
// loyalty, and collapsing that to one mode would lose the reconciliation a
// cashier settles their drawer against.
Paymentsjson string `json:"paymentsjson" gorm:"column:paymentsjson;type:jsonb"`
// Campaigns that fired, stored as amounts rather than rules — a bill read
// back years later must show what was given, not what today's rules give.
Promosjson string `json:"promosjson" gorm:"column:promosjson;type:jsonb"`
// GST per slab, as printed on the tax invoice.
Taxbreakdownjson string `json:"taxbreakdownjson" gorm:"column:taxbreakdownjson;type:jsonb"`
// Which upload carried this bill, and when it landed. Kept for tracing a
// terminal's complaint back to a specific batch.
Batchid string `json:"batchid" gorm:"column:batchid;index"`
Receivedat time.Time `json:"receivedat" gorm:"column:receivedat"`
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
Items []PosOrderItems `json:"items" gorm:"-"`
}
func (PosOrders) TableName() string {
return "pos_orders"
}
// PosOrderItems is one line of a counter bill.
type PosOrderItems struct {
Posorderitemid int `json:"posorderitemid" gorm:"primaryKey;autoIncrement;column:posorderitemid"`
Posorderid int `json:"posorderid" gorm:"column:posorderid;index"`
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
Locationid int `json:"locationid" gorm:"column:locationid;index"`
Productid int `json:"productid" gorm:"column:productid;index"`
// Snapshotted rather than joined. A product renamed or withdrawn next month
// must not change what a bill from today says it sold.
Productname string `json:"productname" gorm:"column:productname"`
Barcode string `json:"barcode" gorm:"column:barcode"`
Unitname string `json:"unitname" gorm:"column:unitname"`
// Fractional: a counter sells 1.5 kg of onions. Note that productstocks
// cannot represent that — see roundStockQty.
Quantity float64 `json:"quantity" gorm:"column:quantity"`
Unitprice float64 `json:"unitprice" gorm:"column:unitprice"`
Discountamount float64 `json:"discountamount" gorm:"column:discountamount"`
// Stored as a fraction (0.18), matching how the till holds it.
Gstrate float64 `json:"gstrate" gorm:"column:gstrate"`
Taxamount float64 `json:"taxamount" gorm:"column:taxamount"`
// What this line contributed to the bill total, after its share of every
// discount. The lines sum to the bill's Total less Roundoff.
Linetotal float64 `json:"linetotal" gorm:"column:linetotal"`
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
}
func (PosOrderItems) TableName() string {
return "pos_order_items"
}

View File

@@ -748,7 +748,17 @@ func (r *orderRepository) GetRevenueSummary(tid, lid int, fdate, tdate string) (
if err := r.db.Raw(overallQuery, overallParams...).Scan(&overallRev).Error; err != nil { if err := r.db.Raw(overallQuery, overallParams...).Scan(&overallRev).Error; err != nil {
return nil, err return nil, err
} }
summary.OverallRevenue = overallRev
// Counter sales live in their own table, so every figure that reads
// `orders` alone understates a shop that runs a till. Added here rather
// than by rewriting the query above: the join and the dynamic parameters
// are load-bearing for app orders and not worth disturbing.
posTotal, posByLocation, err := r.posRevenue(tid, lid, fdate, tdate)
if err != nil {
return nil, err
}
summary.OverallRevenue = overallRev + posTotal
// 4. Fetch revenue details by location // 4. Fetch revenue details by location
locationQuery := ` locationQuery := `
@@ -783,15 +793,163 @@ func (r *orderRepository) GetRevenueSummary(tid, lid int, fdate, tdate string) (
if err := r.db.Raw(locationQuery, locParams...).Scan(&locRevenues).Error; err != nil { if err := r.db.Raw(locationQuery, locParams...).Scan(&locRevenues).Error; err != nil {
return nil, err return nil, err
} }
if locRevenues == nil { if locRevenues == nil {
locRevenues = []models.LocationRevenueDetails{} locRevenues = []models.LocationRevenueDetails{}
} }
// The location list comes from tenantlocations, so an outlet that trades
// only through its counter still has a row here — it just has zero app
// revenue against it. Adding rather than replacing keeps both visible.
for i := range locRevenues {
locRevenues[i].Revenue += posByLocation[locRevenues[i].Locationid]
}
summary.LocationRevenue = locRevenues summary.LocationRevenue = locRevenues
return &summary, nil return &summary, nil
} }
// posSalesTotals returns counter-sale revenue and bill count, overall and by
// day, scoped exactly as GetSalesSummary scopes app orders.
func (r *orderRepository) posSalesTotals(tid, lid int, fdate, tdate string) (
struct {
Revenue float64
Orders int
},
[]models.SalesSummaryChartData,
error,
) {
var totals struct {
Revenue float64
Orders int
}
where := "tenantid = ?"
params := []interface{}{tid}
if lid != 0 {
where += " AND locationid = ?"
params = append(params, lid)
}
if fdate != "" && tdate != "" {
where += " AND businessdate BETWEEN ? AND ?"
params = append(params, fdate, tdate)
}
totalsQuery := fmt.Sprintf(
`SELECT COALESCE(SUM(total), 0) AS revenue, COUNT(posorderid) AS orders
FROM pos_orders WHERE %s`, where)
if err := r.db.Raw(totalsQuery, params...).Scan(&totals).Error; err != nil {
return totals, nil, err
}
dailyQuery := fmt.Sprintf(
`SELECT businessdate AS date, COALESCE(SUM(total), 0) AS revenue,
COUNT(posorderid) AS orders
FROM pos_orders WHERE %s
GROUP BY businessdate ORDER BY businessdate ASC`, where)
var daily []models.SalesSummaryChartData
if err := r.db.Raw(dailyQuery, params...).Scan(&daily).Error; err != nil {
return totals, nil, err
}
return totals, daily, nil
}
// mergePosIntoChart folds counter sales into the app-order series by date.
//
// A day present in one and not the other has to appear rather than be dropped:
// a shop that sells only over the counter has no app orders at all, and an
// inner join on date would show it an empty chart.
func mergePosIntoChart(
app []models.SalesSummaryChartData,
pos []models.SalesSummaryChartData,
) []models.SalesSummaryChartData {
if len(pos) == 0 {
return app
}
// Dates arrive in two shapes — the app series casts a timestamp, the POS
// series stores a plain YYYY-MM-DD string — so both are trimmed to ten
// characters before being matched, or every day would appear twice.
dayOf := func(s string) string {
if len(s) >= 10 {
return s[:10]
}
return s
}
index := make(map[string]int, len(app))
merged := make([]models.SalesSummaryChartData, 0, len(app)+len(pos))
for _, row := range app {
index[dayOf(row.Date)] = len(merged)
merged = append(merged, row)
}
for _, row := range pos {
day := dayOf(row.Date)
if at, ok := index[day]; ok {
merged[at].Revenue += row.Revenue
merged[at].Orders += row.Orders
continue
}
index[day] = len(merged)
merged = append(merged, models.SalesSummaryChartData{
Date: day,
Revenue: row.Revenue,
Orders: row.Orders,
})
}
sort.Slice(merged, func(a, b int) bool {
return dayOf(merged[a].Date) < dayOf(merged[b].Date)
})
return merged
}
// posRevenue totals counter sales, overall and per location.
//
// Scoped the same way GetRevenueSummary scopes app orders — tenant, optional
// location, optional date range — so the two halves of a figure always cover
// the same ground. Dates match on businessdate, which is the day the sale was
// rung rather than the day it reached us: a till that was offline overnight
// uploads yesterday's bills this morning, and they belong to yesterday.
func (r *orderRepository) posRevenue(tid, lid int, fdate, tdate string) (float64, map[int]float64, error) {
query := `SELECT locationid, COALESCE(SUM(total), 0) AS revenue
FROM pos_orders WHERE tenantid = ?`
params := []interface{}{tid}
if lid != 0 {
query += " AND locationid = ?"
params = append(params, lid)
}
if fdate != "" && tdate != "" {
query += " AND businessdate BETWEEN ? AND ?"
params = append(params, fdate, tdate)
}
query += " GROUP BY locationid"
var rows []struct {
Locationid int
Revenue float64
}
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
return 0, nil, err
}
total := 0.0
byLocation := make(map[int]float64, len(rows))
for _, row := range rows {
total += row.Revenue
byLocation[row.Locationid] = row.Revenue
}
return total, byLocation, nil
}
func (r *orderRepository) GetDistinctLocations() ([]models.OrderInsight, error) { func (r *orderRepository) GetDistinctLocations() ([]models.OrderInsight, error) {
var locations []models.OrderInsight var locations []models.OrderInsight
@@ -809,44 +967,52 @@ func (r *orderRepository) GetDistinctLocations() ([]models.OrderInsight, error)
func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*models.SalesSummaryResponse, error) { func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*models.SalesSummaryResponse, error) {
var summary models.SalesSummaryResponse var summary models.SalesSummaryResponse
whereClause := "tenantid = ? AND orderstatus IN ('delivered', 'completed') AND configid = 1" whereClause := "tenantid = ? AND orderstatus IN ('delivered', 'completed') AND configid = 1"
var params []interface{} var params []interface{}
params = append(params, tid) params = append(params, tid)
if lid != 0 { if lid != 0 {
whereClause += " AND locationid = ?" whereClause += " AND locationid = ?"
params = append(params, lid) params = append(params, lid)
} }
if fdate != "" && tdate != "" { if fdate != "" && tdate != "" {
whereClause += " AND orderdate::date BETWEEN ? AND ?" whereClause += " AND orderdate::date BETWEEN ? AND ?"
params = append(params, fdate, tdate) params = append(params, fdate, tdate)
} }
totalsQuery := fmt.Sprintf(` totalsQuery := fmt.Sprintf(`
SELECT SELECT
COALESCE(SUM(COALESCE(ordervalue, 0) + COALESCE(orderamount, 0) + COALESCE(deliveryamt, 0)), 0) AS total_revenue, COALESCE(SUM(COALESCE(ordervalue, 0) + COALESCE(orderamount, 0) + COALESCE(deliveryamt, 0)), 0) AS total_revenue,
COUNT(orderheaderid) AS total_orders COUNT(orderheaderid) AS total_orders
FROM orders FROM orders
WHERE %s`, whereClause) WHERE %s`, whereClause)
var result struct { var result struct {
TotalRevenue float64 TotalRevenue float64
TotalOrders int TotalOrders int
} }
if err := r.db.Raw(totalsQuery, params...).Scan(&result).Error; err != nil { if err := r.db.Raw(totalsQuery, params...).Scan(&result).Error; err != nil {
return nil, err return nil, err
} }
summary.TotalRevenue = result.TotalRevenue // Counter sales, folded in before the average is taken — computing it from
summary.TotalOrders = result.TotalOrders // app orders alone and then adding POS revenue would report an average
// order value no order ever had.
posTotals, posDaily, err := r.posSalesTotals(tid, lid, fdate, tdate)
if err != nil {
return nil, err
}
summary.TotalRevenue = result.TotalRevenue + posTotals.Revenue
summary.TotalOrders = result.TotalOrders + posTotals.Orders
if summary.TotalOrders > 0 { if summary.TotalOrders > 0 {
summary.AverageOrderValue = summary.TotalRevenue / float64(summary.TotalOrders) summary.AverageOrderValue = summary.TotalRevenue / float64(summary.TotalOrders)
} }
chartQuery := fmt.Sprintf(` chartQuery := fmt.Sprintf(`
SELECT SELECT
CAST(orderdate AS DATE) AS date, CAST(orderdate AS DATE) AS date,
@@ -856,17 +1022,21 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m
WHERE %s WHERE %s
GROUP BY CAST(orderdate AS DATE) GROUP BY CAST(orderdate AS DATE)
ORDER BY date ASC`, whereClause) ORDER BY date ASC`, whereClause)
var chartData []models.SalesSummaryChartData var chartData []models.SalesSummaryChartData
if err := r.db.Raw(chartQuery, params...).Scan(&chartData).Error; err != nil { if err := r.db.Raw(chartQuery, params...).Scan(&chartData).Error; err != nil {
return nil, err return nil, err
} }
if chartData == nil { if chartData == nil {
chartData = []models.SalesSummaryChartData{} chartData = []models.SalesSummaryChartData{}
} }
// Merged by day. A shop that only trades over the counter would otherwise
// show a flat line at zero on every chart in the product.
chartData = mergePosIntoChart(chartData, posDaily)
summary.ChartData = chartData summary.ChartData = chartData
var topLocations []models.SalesSummaryTopLocation var topLocations []models.SalesSummaryTopLocation
if lid == 0 { if lid == 0 {
topWhere := "o.tenantid = ? AND o.orderstatus IN ('delivered', 'completed') AND o.configid = 1" topWhere := "o.tenantid = ? AND o.orderstatus IN ('delivered', 'completed') AND o.configid = 1"
@@ -876,7 +1046,7 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m
topWhere += " AND o.orderdate::date BETWEEN ? AND ?" topWhere += " AND o.orderdate::date BETWEEN ? AND ?"
topParams = append(topParams, fdate, tdate) topParams = append(topParams, fdate, tdate)
} }
cleanTopLocQuery := fmt.Sprintf(` cleanTopLocQuery := fmt.Sprintf(`
SELECT SELECT
COALESCE(l.locationname, 'Unknown') AS locationname, COALESCE(l.locationname, 'Unknown') AS locationname,
@@ -887,17 +1057,17 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m
GROUP BY l.locationid, l.locationname GROUP BY l.locationid, l.locationname
ORDER BY revenue DESC ORDER BY revenue DESC
LIMIT 5`, topWhere) LIMIT 5`, topWhere)
if err := r.db.Raw(cleanTopLocQuery, topParams...).Scan(&topLocations).Error; err != nil { if err := r.db.Raw(cleanTopLocQuery, topParams...).Scan(&topLocations).Error; err != nil {
return nil, err return nil, err
} }
} }
if topLocations == nil { if topLocations == nil {
topLocations = []models.SalesSummaryTopLocation{} topLocations = []models.SalesSummaryTopLocation{}
} }
summary.TopLocations = topLocations summary.TopLocations = topLocations
return &summary, nil return &summary, nil
} }
@@ -1220,84 +1390,38 @@ func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models
} }
// 🛠️ Step 0: Lock every (tenantid, locationid, productid) row this order // 🛠️ Step 0: Lock every (tenantid, locationid, productid) row this order
// touches before checking availability. Without this, two concurrent // touches, then check availability under those locks.
// orders for the same product can both read "stock available" before
// either commits its deduction, oversell the item, and drive stock
// negative. Locking productlocations — the row the stock computation is
// already keyed against — serializes conflicting orders instead.
// //
// Locks are acquired in a fixed (productid, locationid) order so that // Both now live in stockLedger.go, shared with the POS ingest — one
// two orders sharing overlapping products always contend for them in // implementation of the rule that stops overselling, rather than one per
// the same sequence, avoiding a lock-ordering deadlock between the two // caller waiting to drift out of step with the others. Behaviour here is
// transactions (as opposed to just making each individually block). // unchanged, including legacyOrderQty's truncate-then-floor-at-1, which is
type lockTarget struct { // what this path has always done.
productid int lines := make([]stockLine, 0, len(data.Items))
locationid int
}
seen := make(map[lockTarget]bool)
locks := make([]lockTarget, 0, len(data.Items))
for _, item := range data.Items { for _, item := range data.Items {
itemLocID := item.Locationid itemLocID := item.Locationid
if itemLocID == 0 { if itemLocID == 0 {
itemLocID = locID itemLocID = locID
} }
lt := lockTarget{productid: item.Productid, locationid: itemLocID} lines = append(lines, stockLine{
if !seen[lt] { Productid: item.Productid,
seen[lt] = true Locationid: itemLocID,
locks = append(locks, lt) Productname: item.Productname,
} Quantity: item.Orderqty,
})
} }
sort.Slice(locks, func(a, b int) bool {
if locks[a].productid != locks[b].productid { if err := lockStockRows(tx, data.Tenantid, lines); err != nil {
return locks[a].productid < locks[b].productid tx.Rollback()
} return models.Orders{}, err
return locks[a].locationid < locks[b].locationid
})
for _, lt := range locks {
var locked int
lockQuery := `SELECT productlocationid FROM productlocations WHERE tenantid = ? AND locationid = ? AND productid = ? FOR UPDATE`
if err := tx.Raw(lockQuery, data.Tenantid, lt.locationid, lt.productid).Scan(&locked).Error; err != nil {
tx.Rollback()
return models.Orders{}, fmt.Errorf("failed to lock stock for product %d: %w", lt.productid, err)
}
} }
// 🛠️ Step 1: Pre-validate stock availability for all items before placing order // 🛠️ Step 1: Pre-validate stock availability for all items before placing order
for _, item := range data.Items { if err := assertStockAvailable(tx, data.Tenantid, lines, func(l stockLine) int {
itemLocID := item.Locationid return legacyOrderQty(l.Quantity)
if itemLocID == 0 { }); err != nil {
itemLocID = locID tx.Rollback()
} return models.Orders{}, err
requestedQty := int(item.Orderqty)
if requestedQty <= 0 {
requestedQty = 1
}
var availableStock int
stockQuery := `
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 productid = ? AND tenantid = ? AND locationid = ?
`
if err := tx.Raw(stockQuery, item.Productid, data.Tenantid, itemLocID).Scan(&availableStock).Error; err != nil {
tx.Rollback()
return models.Orders{}, fmt.Errorf("failed to verify stock for product %d: %w", item.Productid, err)
}
// If stock tracking exists and available stock is less than requested quantity, block order placement
if availableStock < requestedQty {
tx.Rollback()
pName := item.Productname
if pName == "" {
pName = fmt.Sprintf("ID %d", item.Productid)
}
return models.Orders{}, fmt.Errorf("insufficient stock for product '%s': requested %d, available %d", pName, requestedQty, availableStock)
}
} }
// 🛠️ Step 2: Create Order Header // 🛠️ Step 2: Create Order Header
@@ -1331,28 +1455,17 @@ func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models
return models.Orders{}, err return models.Orders{}, err
} }
qty := int(item.Orderqty) // Writes the "out" entry and re-derives the location's availability
if qty <= 0 { // flag from the balance it produced.
qty = 1 if err := recordStockOut(
} tx,
data.Tenantid,
stock := models.Productstock{ stockLine{Productid: item.Productid, Locationid: itemLocID},
Tenantid: data.Tenantid, legacyOrderQty(item.Orderqty),
Stockdate: time.Now(), ); err != nil {
Locationid: itemLocID,
Productid: item.Productid,
Quantity: qty,
Stocktype: "out",
Status: "Active",
}
if err := tx.Table("productstocks").Create(&stock).Error; err != nil {
tx.Rollback() tx.Rollback()
return models.Orders{}, err return models.Orders{}, err
} }
// Re-derive the location's availability flag from the ledger balance
// this "out" entry just produced.
syncProductLocationStatus(tx, data.Tenantid, itemLocID, item.Productid)
} }
// Deliberately not committed: the caller owns the transaction boundary. // Deliberately not committed: the caller owns the transaction boundary.
@@ -2138,7 +2251,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f
var locFilter, dateFilter string var locFilter, dateFilter string
var params []interface{} var params []interface{}
// subquery params // subquery params
params = append(params, tenantID) params = append(params, tenantID)
if locationID != 0 { if locationID != 0 {
@@ -2149,7 +2262,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f
dateFilter = " AND o2.orderdate::date BETWEEN ? AND ?" dateFilter = " AND o2.orderdate::date BETWEEN ? AND ?"
params = append(params, fromDate, toDate) params = append(params, fromDate, toDate)
} }
// main query params // main query params
params = append(params, tenantID) params = append(params, tenantID)
if locationID != 0 { if locationID != 0 {
@@ -2194,7 +2307,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f
if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil { if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil {
return nil, err return nil, err
} }
if data == nil { if data == nil {
data = []models.TimeSeriesData{} data = []models.TimeSeriesData{}
} }

195
repositories/posPresence.go Normal file
View File

@@ -0,0 +1,195 @@
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
}

View File

@@ -0,0 +1,700 @@
package repositories
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"nearle/models"
"gorm.io/gorm"
)
// Ingestion for the Nearle POS terminal.
//
// Bills arrive here already rung up and paid for — the till is the system of
// record until we say otherwise, and it holds its own copy for a week on the
// strength of our acknowledgement. Two consequences shape everything below.
//
// **A duplicate is a success.** Delivery is at-least-once: a lost ack makes a
// terminal re-send bills that are already banked. Reporting those as failures
// would strand a day of takings on the till for ever. So a bill we already hold
// is accepted, silently, without touching stock again.
//
// **Acknowledge only after the commit.** A bill named in the ack is one the
// terminal is entitled to delete. Saying so before the transaction lands would
// trade a real sale for a queue position.
//
// The commit itself is deliberately not reimplemented here. Each bill runs
// through createOrderTx, the same path an app order and a spreadsheet import
// take, so stock deduction, the per-product row locks that prevent overselling,
// the ledger entries and sequence allocation stay shared rather than forked.
type PosRepository interface {
IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error)
IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error)
Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error)
}
type posRepository struct {
db *gorm.DB
// Held rather than embedded so the order machinery is reached explicitly.
orders *orderRepository
}
func NewPosRepository(db *gorm.DB) PosRepository {
return &posRepository{db: db, orders: &orderRepository{db: db}}
}
// resolvePosStore turns the terminal's store_id into an authorised outlet.
//
// The till sends a location and nothing else. The tenant is looked up from it
// here and never accepted from the wire: a terminal that could name its own
// tenant could post sales into somebody else's books.
func (r *posRepository) resolvePosStore(storeID string) (*offlineLocationContext, error) {
locationID, err := strconv.Atoi(strings.TrimSpace(storeID))
if err != nil || locationID <= 0 {
return nil, fmt.Errorf("store_id %q is not a location id; configure the terminal's Store ID with the numeric locationid", storeID)
}
var tenantID int
err = r.db.Raw(
`SELECT COALESCE(MIN(tenantid), 0) FROM tenantlocations WHERE locationid = ?`,
locationID,
).Scan(&tenantID).Error
if err != nil {
return nil, err
}
if tenantID <= 0 {
return nil, fmt.Errorf("no outlet is registered with locationid %d", locationID)
}
return r.orders.resolveOfflineLocationContext(tenantID, locationID)
}
// IngestOrders commits a batch of counter bills and reports what landed.
//
// A failure to resolve the outlet at all returns an error rather than an ack,
// so the terminal treats the outcome as unknown and retries. A failure on one
// bill is reported against that bill alone and the rest still commit.
func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) {
ack := models.NewPosAck(batch.Batchid)
if len(batch.Orders) == 0 {
return ack, nil
}
ctx, err := r.resolvePosStore(batch.Storeid)
if err != nil {
return nil, err
}
products, err := r.orders.loadOfflineProducts(ctx.Tenantid, ctx.Locationid)
if err != nil {
return nil, err
}
if len(products) == 0 {
return nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
}
for _, order := range batch.Orders {
if strings.TrimSpace(order.Id) == "" {
// Nothing to key on, so it can never be deduplicated. Refusing it
// is safer than admitting a bill that would double on every retry.
ack.Reject("", "order is missing its id")
continue
}
if reason := r.importPosOrder(ctx, products, batch.Batchid, order); reason != "" {
ack.Reject(order.Id, reason)
continue
}
ack.Accept(order.Id)
}
return ack, nil
}
// importPosOrder commits one bill, or leaves nothing behind.
//
// Returns an empty string on success — including the case where the bill was
// already held, which is a success from the terminal's point of view.
//
// The bill lands in pos_orders at full fidelity, and the stock it consumed goes
// through the same productstocks ledger an app order uses. Those two facts pull
// in opposite directions and both matter: the bill is its own kind of document
// and deserves its own table, but stock is one number per shelf and must not be
// tracked twice.
func (r *posRepository) importPosOrder(
ctx *offlineLocationContext,
products map[int]offlineProduct,
batchID string,
order models.PosOrder,
) string {
if len(order.Items) == 0 {
return "bill has no items"
}
saleDate, err := parsePosSaleDate(order.Createdat)
if err != nil {
return err.Error()
}
// The till has already apportioned bill-level discounts across its lines to
// get the tax right, but it sends each line at its own pre-apportionment
// value. Left alone, the item rows would sum to the subtotal while the
// header carried the total, and every report that adds up lines would
// disagree with the one that reads the header.
//
// So the lines are scaled onto what was actually collected. The till's own
// figures stay authoritative for the bill as a whole; this only decides how
// that whole is attributed across the lines inside it.
netAmount := order.Total - order.Roundoff
lineSum := 0.0
taxSum := 0.0
for _, item := range order.Items {
lineSum += item.Linetotal
taxSum += item.Tax
}
amountFactor := 1.0
if lineSum > 0 && netAmount > 0 {
amountFactor = netAmount / lineSum
}
taxFactor := 1.0
if taxSum > 0 && order.Tax > 0 {
taxFactor = order.Tax / taxSum
}
items := make([]models.PosOrderItems, 0, len(order.Items))
lines := make([]stockLine, 0, len(order.Items))
var taxTotal float64
for _, raw := range order.Items {
productID, err := strconv.Atoi(strings.TrimSpace(raw.Productid))
if err != nil || productID <= 0 {
return fmt.Sprintf("line '%s' has product_id %q, which is not a catalogue id", raw.Name, raw.Productid)
}
// Membership of this map is the ownership check. A product absent from
// it is either another tenant's or not stocked here, and either way the
// bill is refused rather than posted against a catalogue it has no
// claim on.
product, ok := products[productID]
if !ok {
return fmt.Sprintf("product %d is not stocked at %s", productID, ctx.Locationname)
}
if raw.Quantity <= 0 {
return fmt.Sprintf("product '%s' has a quantity of %g; it must be greater than zero", product.Productname, raw.Quantity)
}
landing := raw.Linetotal * amountFactor
taxAmount := raw.Tax * taxFactor
gross := raw.Unitprice * raw.Quantity
discount := gross - landing
if discount < 0 {
discount = 0
}
taxTotal += taxAmount
items = append(items, models.PosOrderItems{
Tenantid: ctx.Tenantid,
Locationid: ctx.Locationid,
Productid: productID,
Productname: product.Productname,
Barcode: raw.Barcode,
Unitname: product.Productunit,
Quantity: raw.Quantity,
Unitprice: raw.Unitprice,
Discountamount: discount,
Gstrate: raw.Gstrate,
Taxamount: taxAmount,
Linetotal: landing,
})
lines = append(lines, stockLine{
Productid: productID,
Locationid: ctx.Locationid,
Productname: product.Productname,
Quantity: raw.Quantity,
})
}
paymentMode := "cash"
if len(order.Payments) > 0 {
// The largest tender names the bill. A split paid mostly by card with
// ten rupees of change in cash is a card sale in every report anyone
// actually reads — the full split is kept in Paymentsjson regardless.
largest := order.Payments[0]
for _, p := range order.Payments[1:] {
if p.Amount > largest.Amount {
largest = p
}
}
if m := strings.ToLower(strings.TrimSpace(largest.Method)); m != "" {
paymentMode = m
}
}
tx := r.db.Begin()
if tx.Error != nil {
return fmt.Sprintf("could not start a transaction: %v", tx.Error)
}
// Held for the life of the transaction, so a redelivery arriving at the
// same moment waits here and then sees the committed row rather than racing
// past the check below and banking the sale twice. The unique index on
// terminalorderid would catch it either way; this turns a constraint
// violation into an orderly "already held".
lockKey := "possale:" + strings.ToUpper(strings.TrimSpace(order.Id))
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext(?))`, lockKey).Error; err != nil {
tx.Rollback()
return fmt.Sprintf("could not lock bill %s: %v", order.Invoicenumber, err)
}
var already int
err = tx.Raw(
`SELECT COALESCE(COUNT(*), 0) FROM pos_orders WHERE terminalorderid = ?`,
strings.TrimSpace(order.Id),
).Scan(&already).Error
if err != nil {
tx.Rollback()
return fmt.Sprintf("could not check whether bill %s was already held: %v", order.Invoicenumber, err)
}
if already > 0 {
// Already banked. Accepted, not rejected — this is the ordinary result
// of a lost ack, and calling it a failure would leave the till holding
// a bill we have had all along. Stock is deliberately untouched.
tx.Rollback()
return ""
}
// Locks first, then availability, then the writes — the same order an app
// order takes, and the reason two tills selling the last unit cannot both
// succeed.
if err := lockStockRows(tx, ctx.Tenantid, lines); err != nil {
tx.Rollback()
return err.Error()
}
if err := assertStockAvailable(tx, ctx.Tenantid, lines, func(l stockLine) int {
return roundStockQty(l.Quantity)
}); err != nil {
tx.Rollback()
return err.Error()
}
customerID, err := r.orders.resolveOfflineCustomer(tx, ctx, posCustomerName(order), posCustomerMobile(order))
if err != nil {
tx.Rollback()
return fmt.Sprintf("could not resolve the customer: %v", err)
}
bill := models.PosOrders{
Terminalorderid: strings.TrimSpace(order.Id),
Invoicenumber: order.Invoicenumber,
Tenantid: ctx.Tenantid,
Locationid: ctx.Locationid,
Terminalid: order.Terminalid,
Cashiername: order.Cashier,
Customerid: customerID,
Customermobile: posCustomerMobile(order),
Customername: posCustomerName(order),
Billedat: saleDate,
// The day the sale was rung, not the day it arrived. A till that was
// offline overnight uploads yesterday's bills this morning, and every
// daily figure has to follow the sale rather than the upload.
Businessdate: saleDate.Format("2006-01-02"),
Subtotal: order.Subtotal,
Discount: order.Discount,
Taxamount: taxTotal,
Roundoff: order.Roundoff,
Total: order.Total,
Pointsearned: order.Pointsearned,
Pointsredeemed: order.Pointsredeemed,
Itemcount: len(items),
Paymentmode: paymentMode,
Paymentsjson: posJSON(order.Payments),
Promosjson: posJSON(order.Promos),
// Every jsonb column must carry valid JSON. Left at Go's zero value an
// empty string reaches Postgres and the whole insert fails with
// "invalid input syntax for type json" — taking the bill down with it.
Taxbreakdownjson: posJSON(order.Taxbreakdown),
Batchid: batchID,
Receivedat: time.Now(),
}
if err := tx.Create(&bill).Error; err != nil {
tx.Rollback()
return fmt.Sprintf("could not write bill %s: %v", order.Invoicenumber, err)
}
for i := range items {
items[i].Posorderid = bill.Posorderid
if err := tx.Create(&items[i]).Error; err != nil {
tx.Rollback()
return fmt.Sprintf("could not write a line of bill %s: %v", order.Invoicenumber, err)
}
// The same ledger an app order writes to. A second stock ledger for
// counter sales would mean the catalogue pull sends a till figures that
// ignore the till's own trading.
if err := recordStockOut(tx, ctx.Tenantid, lines[i], roundStockQty(lines[i].Quantity)); err != nil {
tx.Rollback()
return fmt.Sprintf("could not deduct stock for bill %s: %v", order.Invoicenumber, err)
}
}
if err := tx.Commit().Error; err != nil {
return fmt.Sprintf("could not commit bill %s: %v", order.Invoicenumber, err)
}
return ""
}
// posJSON encodes a payload column.
//
// Falls back to a JSON null rather than failing the bill: these columns exist
// to be read back later, and losing one is not a reason to refuse a sale the
// shopper has already paid for.
func posJSON(v any) string {
body, err := json.Marshal(v)
if err != nil || len(body) == 0 {
return "null"
}
return string(body)
}
func posCustomerName(order models.PosOrder) string {
if order.Customer == nil {
return ""
}
return order.Customer.Name
}
func posCustomerMobile(order models.PosOrder) string {
if order.Customer == nil {
return ""
}
return order.Customer.Mobile
}
// parsePosSaleDate reads the till's timestamp.
//
// The terminal sends ISO-8601. A blank one falls back to now; an unparseable
// one is refused, because importing a sale under the wrong date corrupts every
// daily revenue figure that reads it.
func parsePosSaleDate(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Now(), nil
}
for _, layout := range []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.999999",
"2006-01-02 15:04:05",
} {
if t, err := time.Parse(layout, raw); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unrecognised created_at %q", raw)
}
// IngestCustomers records shoppers registered at a till.
//
// Insert-if-absent, never an update. A registration is replayed freely, and a
// profile corrected at head office must not be reverted by a terminal replaying
// what it captured months ago.
func (r *posRepository) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) {
ack := models.NewPosAck(batch.Batchid)
if len(batch.Customers) == 0 {
return ack, nil
}
ctx, err := r.resolvePosStore(batch.Storeid)
if err != nil {
return nil, err
}
for _, customer := range batch.Customers {
mobile := strings.TrimSpace(customer.Mobile)
if strings.TrimSpace(customer.Id) == "" || mobile == "" {
ack.Reject(customer.Id, "registration is missing its id or mobile number")
continue
}
if err := r.upsertPosCustomer(ctx, customer, mobile); err != nil {
ack.Reject(customer.Id, err.Error())
continue
}
ack.Accept(customer.Id)
}
return ack, nil
}
// upsertPosCustomer attaches the shopper to this outlet's app location.
//
// Matched on contactno, which is what the rest of the system already keys a
// shopper on, so a shopper registered at a till and one who installed the app
// end up as one row rather than two.
func (r *posRepository) upsertPosCustomer(
ctx *offlineLocationContext,
customer models.PosCustomer,
mobile string,
) error {
name := strings.TrimSpace(customer.Name)
if name == "" {
name = "Counter Customer"
}
var existing int
err := r.db.Raw(
`SELECT COALESCE(MIN(customerid), 0) FROM customers WHERE contactno = ? AND applocationid = ?`,
mobile, ctx.Applocationid,
).Scan(&existing).Error
if err != nil {
return err
}
if existing > 0 {
// Already known. Accepted without a write — the terminal's copy is not
// newer than ours in any way we can establish.
return nil
}
// status 0 mirrors every other customer row in production, including ones
// actively placing orders. A different value here would make a shopper
// registered at the counter behave unlike all the others.
var created int
err = r.db.Raw(`
INSERT INTO customers (configid, firstname, lastname, contactno, email, gender, dob, applocationid, locationid, status, created, updated)
VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, 0, NOW(), NOW())
RETURNING customerid`,
ctx.Configid, name, mobile,
strings.TrimSpace(customer.Email),
strings.TrimSpace(customer.Gender),
strings.TrimSpace(customer.Dateofbirth),
ctx.Applocationid, ctx.Locationid,
).Scan(&created).Error
if err != nil {
return err
}
if created <= 0 {
return errors.New("failed to create the customer row")
}
return nil
}
// Catalogue answers a terminal's morning pull.
//
// Always a full snapshot today, and it says so. The terminal withdraws every
// product a snapshot omits, so answering a change set with is_delta false would
// empty the shelf — declaring false here is only safe because this really does
// return everything stocked at the outlet.
func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
ctx, err := r.resolvePosStore(storeID)
if err != nil {
return nil, err
}
if pageSize <= 0 || pageSize > 1000 {
pageSize = 500
}
if page < 0 {
page = 0
}
type row struct {
Productid int
Productname string
Productsku string
Categoryname string
Productunit string
Productbrand string
Price float64
Retailprice float64
Taxpercent float64
Stock float64
Status string
}
rows := make([]row, 0)
err = r.db.Raw(`
SELECT a.productid,
COALESCE(a.productname, '') AS productname,
COALESCE(a.productsku, '') AS productsku,
COALESCE(c.categoryname, '') AS categoryname,
COALESCE(a.productunit, '') AS productunit,
COALESCE(a.productbrand, '') AS productbrand,
CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price,
COALESCE(a.retailprice, 0) AS retailprice,
COALESCE(a.taxpercent, 0) AS taxpercent,
COALESCE((
SELECT SUM(CASE WHEN LOWER(s.stocktype) = 'in' THEN s.quantity ELSE 0 END) -
SUM(CASE WHEN LOWER(s.stocktype) = 'out' THEN s.quantity ELSE 0 END)
FROM productstocks s
WHERE s.productid = a.productid AND s.tenantid = a.tenantid AND s.locationid = b.locationid
), 0) AS stock,
COALESCE(b.status, '') AS status
FROM products a
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
LEFT JOIN productcategories c ON a.categoryid = c.categoryid
WHERE a.tenantid = ? AND b.locationid = ?
ORDER BY a.productid
LIMIT ? OFFSET ?`,
ctx.Tenantid, ctx.Locationid, pageSize+1, page*pageSize,
).Scan(&rows).Error
if err != nil {
return nil, err
}
// One row beyond the page was requested purely to answer has_more without a
// second count query.
hasMore := len(rows) > pageSize
if hasMore {
rows = rows[:pageSize]
}
products := make([]models.PosCatalogueProduct, 0, len(rows))
for _, p := range rows {
// A productid of zero is bad data, not a product — live data has at
// least one, almost certainly an insert that never got a sequence
// value. Sending it would put a row on the till that can never be
// billed, because the ingest refuses any line whose id is not positive.
if p.Productid <= 0 {
continue
}
mrp := p.Retailprice
if mrp <= p.Price {
mrp = 0
}
// Indian GST is 0/5/12/18/28, but the column holds 3, 4, 6, 7, 9, 10,
// 15 and even -1 across live data. A negative rate would put negative
// tax on a bill and a negative figure in a slab on a filed return, so
// it is floored here rather than trusted.
gstRate := p.Taxpercent / 100
if gstRate < 0 {
gstRate = 0
}
// Withdrawn from sale when there is no selling price. Neither
// productlocations.price nor retailprice is set on much of the estate —
// only productcost is — and a till that can ring an item up at ₹0 is
// worse than one that cannot ring it up at all. Pricing the product
// makes it sellable; nothing here needs changing.
sellable := p.Price > 0 &&
!strings.EqualFold(strings.TrimSpace(p.Status), "outofstock")
products = append(products, models.PosCatalogueProduct{
Id: strconv.Itoa(p.Productid),
Name: p.Productname,
Barcode: posBarcode(p.Productid, p.Productsku),
Sku: p.Productsku,
Category: posCategory(p.Categoryname),
Price: p.Price,
Mrp: mrp,
Stock: p.Stock,
Unit: posUnit(p.Productunit),
Gstrate: gstRate,
Brand: p.Productbrand,
Isactive: sellable,
})
}
return &models.PosCatalogueResponse{
// A revision the terminal stores and sends back on its next pull. Tied
// to the outlet and the moment, so a shop that has pulled today can be
// told it is already current.
Revision: fmt.Sprintf("loc%d-%s", ctx.Locationid, time.Now().UTC().Format("20060102T150405")),
Isdelta: false,
Hasmore: hasMore,
Products: products,
Customers: make([]models.PosCatalogueCustomer, 0),
Retiredids: make([]string, 0),
}, nil
}
// posBarcode decides what the till scans this product by.
//
// The terminal holds a **unique** index on barcode, so whatever this returns has
// to be distinct across the whole catalogue or the import fails outright.
//
// `products.productsku` cannot be trusted for that. Measured against live data:
// 6,245 products carry only 93 distinct SKUs, and the single value "1" is used
// by 5,794 of them. Mapping SKU straight to barcode would collapse most of the
// catalogue onto one row.
//
// So a SKU is used only when it looks like a real scannable code — 8 to 14
// digits, the shape of an EAN-8, UPC-A or EAN-13 — and otherwise the product id
// stands in. The id is unique by construction, which keeps the import working
// today; the day real barcodes are populated, scanning starts working on its own
// with no change here.
//
// Until then, scanning a physical barcode at the till will not find anything.
// That is a data problem, not a code one.
func posBarcode(productID int, sku string) string {
sku = strings.TrimSpace(sku)
if len(sku) >= 8 && len(sku) <= 14 {
digitsOnly := true
for _, r := range sku {
if r < '0' || r > '9' {
digitsOnly = false
break
}
}
if digitsOnly {
return sku
}
}
return strconv.Itoa(productID)
}
// posCategory maps a category name onto one of the terminal's fixed buckets.
//
// The till ships a closed enum, so anything unrecognised has to land somewhere;
// grocery is the catch-all it already uses for uncategorised stock.
func posCategory(name string) string {
switch strings.ToLower(strings.TrimSpace(name)) {
case "dairy":
return "dairy"
case "fruits", "fruit":
return "fruits"
case "vegetables", "vegetable":
return "vegetables"
case "beverages", "beverage", "drinks":
return "beverages"
case "snacks", "snack":
return "snacks"
case "personal care", "personalcare":
return "personalCare"
case "household", "home care", "homecare":
return "household"
default:
return "grocery"
}
}
// posUnit maps a unit of measure onto the terminal's enum, defaulting to pieces.
func posUnit(unit string) string {
switch strings.ToLower(strings.TrimSpace(unit)) {
case "kg", "kilogram", "kilo":
return "kilogram"
case "g", "gram", "grams":
return "gram"
case "l", "litre", "liter":
return "litre"
case "ml", "millilitre", "milliliter":
return "millilitre"
case "pack", "packet":
return "pack"
default:
return "piece"
}
}

View File

@@ -0,0 +1,97 @@
package repositories
import "testing"
// The terminal holds a unique index on barcode, so this rule decides whether a
// catalogue import succeeds at all. Measured against live data when it was
// written: 6,245 products, 93 distinct SKUs, and "1" used by 5,794 of them.
func TestPosBarcodeFallsBackToProductIdWhenTheSkuIsNotScannable(t *testing.T) {
cases := []struct {
name string
productID int
sku string
want string
}{
{"the SKU almost every product shares", 844, "1", "844"},
{"blank SKU", 845, "", "845"},
{"whitespace only", 846, " ", "846"},
{"too short to be a barcode", 847, "1234567", "847"},
{"too long to be a barcode", 848, "123456789012345", "848"},
{"not digits", 849, "SKU-ABC-123", "849"},
{"digits with a space", 850, "1234 5678", "850"},
// Real scannable codes are used as-is, so the day the catalogue carries
// them scanning starts working with no code change.
{"EAN-8", 851, "12345678", "12345678"},
{"UPC-A", 852, "012345678905", "012345678905"},
{"EAN-13", 853, "8901030865278", "8901030865278"},
{"padded EAN-13", 854, " 8901030865278 ", "8901030865278"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := posBarcode(c.productID, c.sku); got != c.want {
t.Errorf("posBarcode(%d, %q) = %q, want %q", c.productID, c.sku, got, c.want)
}
})
}
}
func TestPosBarcodesAreUniqueAcrossACatalogueOfSharedSkus(t *testing.T) {
// The failure this exists to prevent: a whole catalogue collapsing onto one
// barcode and the import being rejected by the terminal's unique index.
seen := make(map[string]int)
for id := 844; id < 844+500; id++ {
barcode := posBarcode(id, "1")
if first, clash := seen[barcode]; clash {
t.Fatalf("products %d and %d both produced barcode %q", first, id, barcode)
}
seen[barcode] = id
}
}
func TestRoundStockQtyNeverUnderDeducts(t *testing.T) {
// productstocks.quantity is an integer column and a counter sells 1.5 kg of
// onions. Rounding up keeps recorded stock at or below what is on the shelf;
// truncating would let the shop oversell a little more with every sale.
cases := []struct {
quantity float64
want int
}{
{1, 1},
{1.5, 2},
{0.25, 1},
{2.0, 2},
{2.01, 3},
{0, 1},
{-1, 1},
}
for _, c := range cases {
if got := roundStockQty(c.quantity); got != c.want {
t.Errorf("roundStockQty(%g) = %d, want %d", c.quantity, got, c.want)
}
}
}
func TestLegacyOrderQtyIsUnchanged(t *testing.T) {
// App orders have always truncated, and that behaviour is deliberately
// preserved rather than corrected — changing it would silently alter stock
// deduction for every order already flowing through createOrderTx.
cases := []struct {
quantity float64
want int
}{
{1, 1},
{1.5, 1},
{0.5, 1},
{3.9, 3},
{0, 1},
}
for _, c := range cases {
if got := legacyOrderQty(c.quantity); got != c.want {
t.Errorf("legacyOrderQty(%g) = %d, want %d", c.quantity, got, c.want)
}
}
}

182
repositories/stockLedger.go Normal file
View File

@@ -0,0 +1,182 @@
package repositories
import (
"fmt"
"math"
"sort"
"time"
"nearle/models"
"gorm.io/gorm"
)
// Shared stock machinery.
//
// Extracted from createOrderTx so that an order placed in the app and a bill
// rung up at a counter deduct stock through exactly the same code. Two
// implementations of the rule that stops overselling would drift, and the first
// anyone would know about it is a shelf that is empty in the database and full
// in the shop, or the reverse.
//
// None of these commit or roll back — the caller owns the transaction boundary,
// because what should happen to the rest of the work on failure is the caller's
// business, not the ledger's.
// stockLine is the minimum the ledger needs to know about one sold line.
//
// Deliberately not models.OrderDetail: the POS ingest writes its own tables and
// has no OrderDetail to hand, and coupling the ledger to one caller's row type
// is what forced the duplication this file removes.
type stockLine struct {
Productid int
Locationid int
Productname string
// Units sold. Fractional because a counter sells 1.5 kg of onions; the
// ledger itself is integer-only, and roundStockQty explains the gap.
Quantity float64
}
// roundStockQty turns a sold quantity into a ledger quantity.
//
// productstocks.quantity is an integer column, so fractional sales cannot be
// represented exactly. Rounding *up* is the conservative direction: 1.5 kg
// deducts 2, so the recorded stock is never higher than what is physically on
// the shelf. Truncating instead would under-deduct on every fractional sale and
// let the shop oversell a little more each time.
//
// This is a workaround, not a fix. A shop that sells much by weight needs the
// column to be numeric.
func roundStockQty(quantity float64) int {
if quantity <= 0 {
return 1
}
return int(math.Ceil(quantity - 1e-9))
}
// lockStockRows takes a row lock on every (tenant, location, product) the sale
// touches, before anything reads availability.
//
// Without it two concurrent sales of the same product can both read "in stock"
// before either commits its deduction, oversell the item and drive the balance
// negative. Locking productlocations — the row the stock computation is already
// keyed against — serialises conflicting sales instead.
//
// Locks are taken in a fixed (productid, locationid) order so two sales sharing
// products always contend in the same sequence. Without that ordering they
// deadlock against each other rather than merely blocking.
func lockStockRows(tx *gorm.DB, tenantID int, lines []stockLine) error {
type lockTarget struct {
productid int
locationid int
}
seen := make(map[lockTarget]bool, len(lines))
locks := make([]lockTarget, 0, len(lines))
for _, line := range lines {
lt := lockTarget{productid: line.Productid, locationid: line.Locationid}
if !seen[lt] {
seen[lt] = true
locks = append(locks, lt)
}
}
sort.Slice(locks, func(a, b int) bool {
if locks[a].productid != locks[b].productid {
return locks[a].productid < locks[b].productid
}
return locks[a].locationid < locks[b].locationid
})
for _, lt := range locks {
var locked int
const q = `SELECT productlocationid FROM productlocations
WHERE tenantid = ? AND locationid = ? AND productid = ? FOR UPDATE`
if err := tx.Raw(q, tenantID, lt.locationid, lt.productid).Scan(&locked).Error; err != nil {
return fmt.Errorf("failed to lock stock for product %d: %w", lt.productid, err)
}
}
return nil
}
// availableStock is the ledger balance for one product at one location.
func availableStock(tx *gorm.DB, tenantID, locationID, productID int) (int, error) {
var available int
const q = `
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 productid = ? AND tenantid = ? AND locationid = ?`
if err := tx.Raw(q, productID, tenantID, locationID).Scan(&available).Error; err != nil {
return 0, fmt.Errorf("failed to verify stock for product %d: %w", productID, err)
}
return available, nil
}
// assertStockAvailable refuses the whole sale if any line cannot be met.
//
// Checked for every line before any is written, so a sale never lands
// half-deducted. Call it only with the locks from lockStockRows already held —
// otherwise the balance it reads can change before the deduction is written.
func assertStockAvailable(tx *gorm.DB, tenantID int, lines []stockLine, qtyOf func(stockLine) int) error {
for _, line := range lines {
available, err := availableStock(tx, tenantID, line.Locationid, line.Productid)
if err != nil {
return err
}
requested := qtyOf(line)
if available < requested {
name := line.Productname
if name == "" {
name = fmt.Sprintf("ID %d", line.Productid)
}
return fmt.Errorf(
"insufficient stock for product '%s': requested %d, available %d",
name, requested, available,
)
}
}
return nil
}
// recordStockOut writes the ledger entry for one sold line and re-derives the
// location's availability flag from the balance it just produced.
func recordStockOut(tx *gorm.DB, tenantID int, line stockLine, quantity int) error {
stock := models.Productstock{
Tenantid: tenantID,
Stockdate: time.Now(),
Locationid: line.Locationid,
Productid: line.Productid,
Quantity: quantity,
Stocktype: "out",
Status: "Active",
}
if err := tx.Table("productstocks").Create(&stock).Error; err != nil {
return err
}
syncProductLocationStatus(tx, tenantID, line.Locationid, line.Productid)
return nil
}
// legacyOrderQty is how createOrderTx has always turned an order quantity into
// a ledger quantity: truncate, then floor at 1.
//
// Preserved exactly rather than corrected, because changing it would silently
// alter stock deduction for every app order in production. It under-deducts a
// fractional line — 1.5 becomes 1 — which is why the POS path uses
// roundStockQty instead. Worth reconciling once someone owns the decision.
func legacyOrderQty(quantity float64) int {
q := int(quantity)
if q <= 0 {
q = 1
}
return q
}

31
routes/posroutes.go Normal file
View File

@@ -0,0 +1,31 @@
package routes
import (
"nearle/facade"
"github.com/gofiber/fiber/v2"
)
// Routes for the Nearle POS terminal.
//
// The paths are fixed by the till, which appends `/orders`, `/customers` and
// `/catalogue` to whatever base URL a shop enters in Settings. Set that base to
// this group — `https://your-host/live/api/v1/pos` — and the three line up.
//
// Kept in their own group rather than folded into the order routes because a
// terminal authenticates as a device, not as a signed-in user, and because
// these answer with a bare ack rather than the web app's response envelope.
func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
pos := api.Group("/v1/pos")
pos.Post("/orders", f.PosController.IngestOrders)
pos.Post("/customers", f.PosController.IngestCustomers)
pos.Get("/catalogue", f.PosController.Catalogue)
// Terminal presence, read from Redis. What the rider app's POS board and a
// support call both hit — the tills themselves publish health over the
// broker rather than posting it here.
pos.Get("/health/terminal", f.PosController.TerminalHealth)
pos.Get("/health/location", f.PosController.LocationHealth)
}

View File

@@ -19,4 +19,5 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
RegisterPartnerRoutes(api, f) RegisterPartnerRoutes(api, f)
RegisterCustomerRoutes(api, f) RegisterCustomerRoutes(api, f)
RegisterCatalogueRoutes(api, f) RegisterCatalogueRoutes(api, f)
RegisterPosRoutes(api, f)
} }

View File

@@ -0,0 +1,116 @@
package main
import (
"fmt"
"gorm.io/gorm"
)
// cleanup removes everything the end-to-end proof wrote to the live database.
//
// Three things, in an order that leaves nothing half-undone: the stock the test
// bills consumed is returned, the bills themselves are deleted, and the price
// that was set to make a product sellable goes back to what it was.
//
// The two test bills are named explicitly rather than deleted by date or by
// "everything in pos_orders" — a table that will hold real takings tomorrow is
// not one to run an unbounded DELETE against.
const probeMobile = "9840012345"
var testOrderIDs = []string{
"11111111-2222-4333-8444-555555555555", // the HTTP probe
"99999999-8888-4777-8666-555555555555", // the MQTT probe
}
func cleanup(db *gorm.DB) {
var billIDs []int
db.Raw(`SELECT posorderid FROM pos_orders WHERE terminalorderid IN ?`,
testOrderIDs).Scan(&billIDs)
if len(billIDs) == 0 {
fmt.Println("no test bills found — nothing to undo")
}
// Stock first. Deleting the bills before returning what they consumed would
// leave the ledger short with nothing left to explain why.
var consumed []struct {
Productid int
Quantity float64
}
db.Raw(`SELECT productid, SUM(quantity) AS quantity
FROM pos_order_items WHERE posorderid IN ?
GROUP BY productid`, billIDs).Scan(&consumed)
for _, c := range consumed {
qty := int(c.Quantity)
if float64(qty) < c.Quantity {
qty++ // the ingest rounded up, so the reversal must too
}
if err := db.Exec(`
INSERT INTO productstocks (tenantid, stockdate, locationid, productid,
quantity, stocktype, status)
VALUES (?, NOW(), ?, ?, ?, 'in', 'Active')`,
tenantID, locationID, c.Productid, qty).Error; err != nil {
fmt.Println(" return stock:", err)
continue
}
fmt.Printf(" returned %d units of product %d\n", qty, c.Productid)
}
if len(billIDs) > 0 {
db.Exec(`DELETE FROM pos_order_items WHERE posorderid IN ?`, billIDs)
db.Exec(`DELETE FROM pos_orders WHERE posorderid IN ?`, billIDs)
fmt.Printf(" deleted %d test bill(s)\n", len(billIDs))
}
db.Exec(`UPDATE productlocations SET price = 0
WHERE tenantid=? AND locationid=? AND productid=?`,
tenantID, locationID, productID)
fmt.Printf(" product %d price restored to 0\n", productID)
// The shopper the probes created. Removed only when nothing references it —
// a customer row attached to a real order is not test data any more.
var referenced int
db.Raw(`SELECT COUNT(*) FROM orders WHERE customerid =
(SELECT MIN(customerid) FROM customers WHERE contactno = ?)`,
probeMobile).Scan(&referenced)
if referenced > 0 {
fmt.Printf(" customer %s left in place — %d order(s) reference it\n",
probeMobile, referenced)
} else {
res := db.Exec(`DELETE FROM customers WHERE contactno = ?`, probeMobile)
fmt.Printf(" removed %d probe customer row(s)\n", res.RowsAffected)
}
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(" product %d balance back to %.0f\n", productID, balance)
}
// showCustomer reports whether the registration probe reached the customers
// table, and under which id — the question an ack alone cannot answer.
func showCustomer(db *gorm.DB, mobile string) {
var rows []struct {
Customerid int
Firstname string
Contactno string
Applocationid int
}
db.Raw(`SELECT customerid, COALESCE(firstname,'') AS firstname,
COALESCE(contactno,'') AS contactno,
COALESCE(applocationid,0) AS applocationid
FROM customers WHERE contactno = ?`, mobile).Scan(&rows)
if len(rows) == 0 {
fmt.Printf(" no customer with contactno %s\n", mobile)
return
}
for _, r := range rows {
fmt.Printf(" customerid=%d %q contactno=%s applocid=%d\n",
r.Customerid, r.Firstname, r.Contactno, r.Applocationid)
}
}

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)
}
}

122
scratch/mqttpub/main.go Normal file
View File

@@ -0,0 +1,122 @@
// Publishes a bill over the real broker and waits for the ack, standing in for
// a till until one is available to test with.
//
// go run ./scratch/mqttpub
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/joho/godotenv"
)
const (
locationID = "1135"
terminalID = "T4A9"
orderID = "99999999-8888-4777-8666-555555555555" // distinct from the HTTP test
)
func main() {
_ = godotenv.Load()
broker := os.Getenv("MQTT_URL")
if broker == "" {
broker = "tcp://66.116.225.226:1883"
}
opts := mqtt.NewClientOptions().
AddBroker(broker).
SetClientID("pos-e2e-probe").
SetUsername(os.Getenv("MQTT_USER")).
SetPassword(os.Getenv("MQTT_PASSWORD")).
SetCleanSession(true)
client := mqtt.NewClient(opts)
if t := client.Connect(); t.Wait() && t.Error() != nil {
log.Fatal("connect:", t.Error())
}
defer client.Disconnect(500)
fmt.Println("connected to", broker)
acks := make(chan []byte, 1)
ackTopic := fmt.Sprintf("nearle/pos/%s/%s/ack", locationID, terminalID)
if t := client.Subscribe(ackTopic, 1, func(_ mqtt.Client, m mqtt.Message) {
acks <- m.Payload()
}); t.Wait() && t.Error() != nil {
log.Fatal("subscribe:", t.Error())
}
fmt.Println("listening on", ackTopic)
batch := map[string]any{
"schema": 1,
"batch_id": "batch-mqtt-0001",
"store_id": locationID,
"terminal_id": terminalID,
"sent_at": time.Now().UTC().Format(time.RFC3339),
"orders": []map[string]any{{
"id": orderID,
"invoice_number": "INV-2608-T4A9-00002",
"created_at": time.Now().UTC().Format(time.RFC3339),
"terminal_id": terminalID,
"cashier": "Divya",
"customer": map[string]any{"id": "c-2", "mobile": "9840099999", "name": "Ravi"},
"subtotal": 60.0,
"discount": 0.0,
"promos": []any{},
"tax": 4.44,
"tax_breakdown": map[string]float64{"0.08": 4.44},
"round_off": 0.0,
"total": 60.0,
"points_earned": 0,
"points_redeemed": 0,
"payments": []map[string]any{{"method": "upi", "amount": 60.0, "reference": "TESTUPI"}},
"items": []map[string]any{{
"product_id": "6988", "barcode": "6988", "name": "Mysore Banana",
"quantity": 1, "unit_price": 60.0, "discount": 0.0,
"gst_rate": 0.08, "tax": 4.44, "line_total": 60.0,
}},
}},
}
body, _ := json.Marshal(batch)
orderTopic := fmt.Sprintf("nearle/pos/%s/%s/order", locationID, terminalID)
if t := client.Publish(orderTopic, 1, false, body); t.Wait() && t.Error() != nil {
log.Fatal("publish:", t.Error())
}
fmt.Println("published a bill to", orderTopic)
// The same 20 seconds a real till waits before giving up and re-sending.
select {
case payload := <-acks:
fmt.Println("\nACK RECEIVED:")
var pretty map[string]any
_ = json.Unmarshal(payload, &pretty)
out, _ := json.MarshalIndent(pretty, " ", " ")
fmt.Println(" " + string(out))
case <-time.After(20 * time.Second):
fmt.Println("\nNO ACK within 20s — a real till would keep the bill and send it again")
os.Exit(1)
}
// A heartbeat too, so the Redis presence path is exercised.
health, _ := json.Marshal(map[string]any{
"schema": 1, "status": "online",
"terminal_id": terminalID, "location_id": locationID,
"store_name": "Ragul stores Selvapuram", "app_version": "1.1.0",
"pending_bills": 0, "pending_registrations": 0,
"today_bills": 2, "today_amount": 170.0,
"printer_reachable": false,
"reported_at": time.Now().UTC().Format(time.RFC3339),
})
healthTopic := fmt.Sprintf("nearle/pos/%s/%s/health", locationID, terminalID)
if t := client.Publish(healthTopic, 1, false, health); t.Wait() && t.Error() != nil {
log.Fatal("publish health:", t.Error())
}
fmt.Println("\npublished a heartbeat to", healthTopic)
time.Sleep(2 * time.Second)
}

55
services/posService.go Normal file
View File

@@ -0,0 +1,55 @@
package services
import (
"context"
"nearle/models"
"nearle/repositories"
)
type PosService interface {
IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error)
IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error)
Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error)
// RecordHealth stores one heartbeat. Never acknowledged back to the till:
// presence is a fire-and-forget signal, and a terminal that stopped selling
// because its heartbeat failed would be a worse outcome than a blank board.
RecordHealth(ctx context.Context, health models.PosHealth) error
TerminalHealth(ctx context.Context, terminalID string) (map[string]string, error)
LocationHealth(ctx context.Context, locationID string) ([]map[string]string, error)
}
type posService struct {
repo repositories.PosRepository
presence repositories.PosPresenceRepository
}
func NewPosService(repo repositories.PosRepository, presence repositories.PosPresenceRepository) PosService {
return &posService{repo: repo, presence: presence}
}
func (s *posService) RecordHealth(ctx context.Context, health models.PosHealth) error {
return s.presence.Record(ctx, health)
}
func (s *posService) TerminalHealth(ctx context.Context, terminalID string) (map[string]string, error) {
return s.presence.Terminal(ctx, terminalID)
}
func (s *posService) LocationHealth(ctx context.Context, locationID string) ([]map[string]string, error) {
return s.presence.Location(ctx, locationID)
}
func (s *posService) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) {
return s.repo.IngestOrders(batch)
}
func (s *posService) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) {
return s.repo.IngestCustomers(batch)
}
func (s *posService) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
return s.repo.Catalogue(storeID, since, page, pageSize)
}