diff --git a/POS_TERMINAL_INGEST.md b/POS_TERMINAL_INGEST.md new file mode 100644 index 0000000..0d8dfe4 --- /dev/null +++ b/POS_TERMINAL_INGEST.md @@ -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 '' +mosquitto_passwd -b /mosquitto/config/passwd pos_ingest '' +``` + +```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 3–4× 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. diff --git a/controllers/posController.go b/controllers/posController.go new file mode 100644 index 0000000..86031ea --- /dev/null +++ b/controllers/posController.go @@ -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, + }) +} diff --git a/db/redis.go b/db/redis.go new file mode 100644 index 0000000..6126dee --- /dev/null +++ b/db/redis.go @@ -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) + } +} diff --git a/facade/container.go b/facade/container.go index 3a88e31..daf22bb 100644 --- a/facade/container.go +++ b/facade/container.go @@ -9,16 +9,21 @@ import ( ) type Facade struct { - UserController *controllers.UserController - ProductController *controllers.ProductController - OrderController *controllers.OrderController - DeliveriesController *controllers.DeliveriesController - UtilsController *controllers.UtilsController - TenantController *controllers.TenantController - PartnerController *controllers.PartnerController + UserController *controllers.UserController + ProductController *controllers.ProductController + OrderController *controllers.OrderController + DeliveriesController *controllers.DeliveriesController + UtilsController *controllers.UtilsController + TenantController *controllers.TenantController + PartnerController *controllers.PartnerController CustomerController *controllers.CustomerController StockRequestController *controllers.StockRequestController 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. @@ -79,16 +84,33 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade { stockRequestService := services.NewStockRequestService(stockRequestRepo, productService) 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{ - UserController: userController, - ProductController: productController, - OrderController: orderController, - DeliveriesController: deliveriesController, - UtilsController: utilsController, - TenantController: tenantController, + UserController: userController, + ProductController: productController, + OrderController: orderController, + DeliveriesController: deliveriesController, + UtilsController: utilsController, + TenantController: tenantController, PartnerController: partnerController, CustomerController: customerController, StockRequestController: stockRequestController, 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 } diff --git a/go.mod b/go.mod index 9797ad6..5e00842 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,21 @@ module nearle go 1.24 -toolchain go1.24.0 - -require gorm.io/gorm v1.25.10 +require ( + firebase.google.com/go v3.13.0+incompatible + 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 ( 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/longrunning v0.5.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/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/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/internal/configsources v1.4.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/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/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/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/sts v1.44.1 // 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/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/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // 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/uuid v1.4.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.1 // indirect github.com/googleapis/gax-go/v2 v2.12.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/jackc/pgpassfile v1.0.0 // 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/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/joho/godotenv v1.5.1 // indirect - github.com/klauspost/compress v1.17.2 // indirect + github.com/klauspost/compress v1.19.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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/slog-shim v0.1.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/tcplisten v1.0.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 golang.org/x/crypto v0.31.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/net v0.21.0 // indirect - golang.org/x/oauth2 v0.12.0 // indirect + golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/time v0.3.0 // 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/genproto 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/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gorm.io/driver/postgres v1.6.0 // indirect ) require ( @@ -99,5 +105,4 @@ require ( golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gorm.io/driver/mysql v1.5.2 ) diff --git a/go.sum b/go.sum index 71179bd..8e90749 100644 --- a/go.sum +++ b/go.sum @@ -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/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= 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/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/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 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.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/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.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 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/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-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/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM= 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.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.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +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/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.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-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 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/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY= 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.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 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/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.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= -github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +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/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 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.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +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/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U= 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.32/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.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 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.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= 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/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 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-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.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/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 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.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -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/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 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-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-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.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/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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-20220811171246-fbc7d0a398ab/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/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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.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.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/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 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= 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-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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/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/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/main.go b/main.go index 748b780..f91980c 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "log" "nearle/db" "nearle/facade" + "nearle/messaging" "nearle/models" "nearle/routes" "os" @@ -39,13 +40,36 @@ func main() { db.Connect() 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 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) 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 go func() { if err := app.Listen(":1122"); err != nil { @@ -53,7 +77,7 @@ func main() { } }() - gracefulShutdown() + gracefulShutdown(posMqtt) } func selectDBMiddleware(c *fiber.Ctx) error { @@ -78,13 +102,18 @@ func selectDBMiddleware(c *fiber.Ctx) error { return c.Next() } -func gracefulShutdown() { +func gracefulShutdown(posMqtt *messaging.PosMqttConsumer) { c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) <-c 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 // Example: // closeDB(db.DB_DEV) diff --git a/messaging/posmqtt.go b/messaging/posmqtt.go new file mode 100644 index 0000000..9957eb5 --- /dev/null +++ b/messaging/posmqtt.go @@ -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///`. +// +// 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 +} diff --git a/messaging/posmqtt_test.go b/messaging/posmqtt_test.go new file mode 100644 index 0000000..82d0670 --- /dev/null +++ b/messaging/posmqtt_test.go @@ -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) + } +} diff --git a/models/pos.go b/models/pos.go new file mode 100644 index 0000000..8d65ec5 --- /dev/null +++ b/models/pos.go @@ -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"` +} diff --git a/models/poshealth.go b/models/poshealth.go new file mode 100644 index 0000000..c6b658c --- /dev/null +++ b/models/poshealth.go @@ -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"` +} diff --git a/models/posorder.go b/models/posorder.go new file mode 100644 index 0000000..33c85a5 --- /dev/null +++ b/models/posorder.go @@ -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" +} diff --git a/repositories/orderRepository.go b/repositories/orderRepository.go index ae9b81f..7497dd5 100644 --- a/repositories/orderRepository.go +++ b/repositories/orderRepository.go @@ -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 { 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 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 { return nil, err } - + if locRevenues == nil { 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 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) { 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) { var summary models.SalesSummaryResponse - + whereClause := "tenantid = ? AND orderstatus IN ('delivered', 'completed') AND configid = 1" var params []interface{} params = append(params, tid) - + if lid != 0 { whereClause += " AND locationid = ?" params = append(params, lid) } - + if fdate != "" && tdate != "" { whereClause += " AND orderdate::date BETWEEN ? AND ?" params = append(params, fdate, tdate) } - + totalsQuery := fmt.Sprintf(` SELECT COALESCE(SUM(COALESCE(ordervalue, 0) + COALESCE(orderamount, 0) + COALESCE(deliveryamt, 0)), 0) AS total_revenue, COUNT(orderheaderid) AS total_orders FROM orders WHERE %s`, whereClause) - + var result struct { TotalRevenue float64 TotalOrders int } - + if err := r.db.Raw(totalsQuery, params...).Scan(&result).Error; err != nil { return nil, err } - - summary.TotalRevenue = result.TotalRevenue - summary.TotalOrders = result.TotalOrders - + + // Counter sales, folded in before the average is taken — computing it from + // 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 { summary.AverageOrderValue = summary.TotalRevenue / float64(summary.TotalOrders) } - + chartQuery := fmt.Sprintf(` SELECT CAST(orderdate AS DATE) AS date, @@ -856,17 +1022,21 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m WHERE %s GROUP BY CAST(orderdate AS DATE) ORDER BY date ASC`, whereClause) - + var chartData []models.SalesSummaryChartData if err := r.db.Raw(chartQuery, params...).Scan(&chartData).Error; err != nil { return nil, err } - + if chartData == nil { 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 - + var topLocations []models.SalesSummaryTopLocation if lid == 0 { 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 ?" topParams = append(topParams, fdate, tdate) } - + cleanTopLocQuery := fmt.Sprintf(` SELECT 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 ORDER BY revenue DESC LIMIT 5`, topWhere) - + if err := r.db.Raw(cleanTopLocQuery, topParams...).Scan(&topLocations).Error; err != nil { return nil, err } } - + if topLocations == nil { topLocations = []models.SalesSummaryTopLocation{} } summary.TopLocations = topLocations - + 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 - // touches before checking availability. Without this, two concurrent - // 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. + // touches, then check availability under those locks. // - // Locks are acquired in a fixed (productid, locationid) order so that - // two orders sharing overlapping products always contend for them in - // the same sequence, avoiding a lock-ordering deadlock between the two - // transactions (as opposed to just making each individually block). - type lockTarget struct { - productid int - locationid int - } - seen := make(map[lockTarget]bool) - locks := make([]lockTarget, 0, len(data.Items)) + // Both now live in stockLedger.go, shared with the POS ingest — one + // implementation of the rule that stops overselling, rather than one per + // caller waiting to drift out of step with the others. Behaviour here is + // unchanged, including legacyOrderQty's truncate-then-floor-at-1, which is + // what this path has always done. + lines := make([]stockLine, 0, len(data.Items)) for _, item := range data.Items { itemLocID := item.Locationid if itemLocID == 0 { itemLocID = locID } - lt := lockTarget{productid: item.Productid, locationid: itemLocID} - if !seen[lt] { - seen[lt] = true - locks = append(locks, lt) - } + lines = append(lines, stockLine{ + Productid: item.Productid, + Locationid: itemLocID, + Productname: item.Productname, + Quantity: item.Orderqty, + }) } - 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 - 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) - } + + if err := lockStockRows(tx, data.Tenantid, lines); err != nil { + tx.Rollback() + return models.Orders{}, err } // 🛠️ Step 1: Pre-validate stock availability for all items before placing order - for _, item := range data.Items { - itemLocID := item.Locationid - if itemLocID == 0 { - itemLocID = locID - } - - 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) - } + if err := assertStockAvailable(tx, data.Tenantid, lines, func(l stockLine) int { + return legacyOrderQty(l.Quantity) + }); err != nil { + tx.Rollback() + return models.Orders{}, err } // 🛠️ Step 2: Create Order Header @@ -1331,28 +1455,17 @@ func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models return models.Orders{}, err } - qty := int(item.Orderqty) - if qty <= 0 { - qty = 1 - } - - stock := models.Productstock{ - Tenantid: data.Tenantid, - Stockdate: time.Now(), - Locationid: itemLocID, - Productid: item.Productid, - Quantity: qty, - Stocktype: "out", - Status: "Active", - } - if err := tx.Table("productstocks").Create(&stock).Error; err != nil { + // Writes the "out" entry and re-derives the location's availability + // flag from the balance it produced. + if err := recordStockOut( + tx, + data.Tenantid, + stockLine{Productid: item.Productid, Locationid: itemLocID}, + legacyOrderQty(item.Orderqty), + ); err != nil { tx.Rollback() 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. @@ -2138,7 +2251,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f var locFilter, dateFilter string var params []interface{} - + // subquery params params = append(params, tenantID) if locationID != 0 { @@ -2149,7 +2262,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f dateFilter = " AND o2.orderdate::date BETWEEN ? AND ?" params = append(params, fromDate, toDate) } - + // main query params params = append(params, tenantID) 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 { return nil, err } - + if data == nil { data = []models.TimeSeriesData{} } diff --git a/repositories/posPresence.go b/repositories/posPresence.go new file mode 100644 index 0000000..636623d --- /dev/null +++ b/repositories/posPresence.go @@ -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 +} diff --git a/repositories/posRepository.go b/repositories/posRepository.go new file mode 100644 index 0000000..3980d7c --- /dev/null +++ b/repositories/posRepository.go @@ -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" + } +} diff --git a/repositories/posRepository_test.go b/repositories/posRepository_test.go new file mode 100644 index 0000000..425c2c1 --- /dev/null +++ b/repositories/posRepository_test.go @@ -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) + } + } +} diff --git a/repositories/stockLedger.go b/repositories/stockLedger.go new file mode 100644 index 0000000..8269e61 --- /dev/null +++ b/repositories/stockLedger.go @@ -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 +} diff --git a/routes/posroutes.go b/routes/posroutes.go new file mode 100644 index 0000000..66ee3ac --- /dev/null +++ b/routes/posroutes.go @@ -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) +} diff --git a/routes/routes.go b/routes/routes.go index b6b9f91..18eda2b 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -19,4 +19,5 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) { RegisterPartnerRoutes(api, f) RegisterCustomerRoutes(api, f) RegisterCatalogueRoutes(api, f) + RegisterPosRoutes(api, f) } diff --git a/scratch/dbinspect/cleanup.go b/scratch/dbinspect/cleanup.go new file mode 100644 index 0000000..ca89829 --- /dev/null +++ b/scratch/dbinspect/cleanup.go @@ -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) + } +} diff --git a/scratch/dbinspect/main.go b/scratch/dbinspect/main.go new file mode 100644 index 0000000..966ea73 --- /dev/null +++ b/scratch/dbinspect/main.go @@ -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) + } +} diff --git a/scratch/mqttpub/main.go b/scratch/mqttpub/main.go new file mode 100644 index 0000000..df9d1cf --- /dev/null +++ b/scratch/mqttpub/main.go @@ -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) +} diff --git a/services/posService.go b/services/posService.go new file mode 100644 index 0000000..04534b6 --- /dev/null +++ b/services/posService.go @@ -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) +}