Untracked in the working tree before today's work; committed so the branch carries everything on disk except a stray duplicate (docs/MOBILE_ORDER_VERIFICATION copy.md). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
45 KiB
Nearle "Fiesta" backend — what was built
A Go monolith that serves a multi-tenant retail platform: neighbourhood shops (tenants) with one or more outlets, selling through a consumer app, a rider delivery fleet, and — the newest and largest piece of work — physical point-of-sale terminals sitting on shop counters.
There are really five distinct systems in here. They are ordered below by how much original engineering they represent.
1. The POS terminal integration (the centrepiece)
What it is
Retail tills in shops run a Flutter app with its own local SQLite database. They keep selling with no network at all — a village shop's connection drops for hours. When connectivity returns, each till uploads the bills it rang while offline, pulls down an updated product catalogue, and reports its own health.
This backend is the other end of that conversation. It has to solve the classic offline-first sync problem under a hard constraint: a sale that has already been paid for in cash must never be lost, and must never be counted twice.
The problem it solves for the business: shops that were doing counter sales entirely off-platform now have those sales in the same database as their app orders, deducting from the same stock, appearing in the same revenue reports.
How it's built
┌─────────────────────────────────────────────────────────┐
│ Till (Flutter + SQLite) — not in this repo │
│ sale committed locally first, sync_status = 0 │
└───────────────┬──────────────────────────┬──────────────┘
│ │
(transport A) MQTT (transport B) HTTPS
nearle/pos/{loc}/{term}/order POST /live/api/v1/pos/orders
nearle/pos/{loc}/{term}/customer POST .../customers
nearle/pos/{loc}/{term}/health POST .../health
│ │
▼ ▼
┌──────────────────────┐ ┌────────────────────────┐
│ messaging/posmqtt.go │ │ controllers/ │
│ • leader election │ │ posController.go │
│ • bounded worker │ │ • PosAuth middleware │
│ pools (ingest, │ │ verifies token + │
│ health) │ │ outlet ownership │
└──────────┬───────────┘ └───────────┬────────────┘
└────────────┬──────────────┘
▼
services.PosService ← one code path for both
│
┌─────────────────┼──────────────────┐
▼ ▼ ▼
posRepository posSalesRepository posPresence
(ingest, catalogue) (read-back) (Redis)
│ │ │
▼ ▼ ▼
┌──────────────────────────────┐ ┌──────────────┐
│ POSTGRES (nearledb) │ │ REDIS │
│ pos_orders │ │ pos:terminal:│
│ pos_order_items │ │ {id} HASH │
│ productstocks ← shared │ │ TTL 90s │
│ customers, app_users │ │ pos:location:│
└──────────────────────────────┘ │ {id} SET │
└──────────────┘
│
▼
ack → nearle/pos/{loc}/{term}/ack (or HTTP 200 body)
│
▼
Till marks bill synced, keeps its copy 7 more days
The storage split is deliberate and explained in the code:
- Postgres
pos_orders/pos_order_items— the permanent record of counter bills, kept separate from the app'sorderstable. A bill carries a cashier, a terminal id, a rounding adjustment, promo campaigns, loyalty movement and a payment split across several tenders;ordershas nowhere to put any of that. The stated cost of the split is that every revenue query has to union both — which was done, inorderRepository.posRevenueandposSalesTotals. - Postgres
productstocks— stock is deliberately not split. A counter sale writes the same "out" ledger rows an app order does, through the same helper, so the catalogue pushed down to a till reflects the till's own trading. - Redis — terminal presence only, with a 90-second TTL. Shared with a separate
Express backend (the rider app reads the board), namespaced
pos:*so it cannot collide with that service'sdelivery:*/city:*keys.
How the problem was solved — the approach
Six interlocking decisions.
(a) The acknowledgement protocol is the whole design. Delivery is
at-least-once. The rule, stated in models.PosAck: 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 gets re-sent. rejected is a separate, deliberate verdict meaning "stop
retrying this one, fetch a human" — used for a malformed bill, never for "the
database is having a bad minute." That distinction is enforced right up in the
HTTP layer: posIngestError decides 4xx (permanent — the till halts and shows a
person) versus 5xx (unknown — the till keeps everything and backs off).
(b) Idempotency: a duplicate is a success, not a failure. Each bill carries a
UUID minted at the till, stored as terminalorderid with a unique index. On
arrival, importPosOrder opens a transaction, takes
pg_advisory_xact_lock(hashtext('possale:' || id)), then checks whether the bill
is already held. If it is, the transaction rolls back and the bill is
accepted — stock untouched. Calling a re-delivered bill a failure would strand
a day's takings on the till forever. The advisory lock turns what would otherwise
be a unique-constraint violation into an orderly "already held," and closes the
race where two redeliveries arrive simultaneously.
(c) Ordering inside the transaction — locks, then availability, then writes.
stockLedger.go holds the shared machinery. lockStockRows takes
SELECT … FOR UPDATE on every (tenant, location, product) the sale touches,
sorted by (productid, locationid) so two concurrent sales sharing products
always contend in the same sequence and block rather than deadlock. Only then does
assertStockAvailable read balances, and only then are rows written. This is the
same path an app order and a spreadsheet import take — the code was extracted
specifically so there aren't three implementations of the anti-overselling rule
drifting apart.
(d) Line-item reconciliation. A subtle one. The till has already apportioned
bill-level discounts across its lines to get tax right, but it sends each line at
its pre-apportionment value. Left alone, summing line items gives the subtotal
while the header carries the total, and two reports disagree. So the code computes
amountFactor = (total − roundoff) / Σ line_total and
taxFactor = header_tax / Σ line_tax, and scales each line onto what was actually
collected. The till stays authoritative for the bill as a whole; this only decides
attribution within it.
(e) The catalogue downlink is a delta protocol with a safety invariant.
GET /pos/catalogue answers either a full snapshot or a change set. The terminal
treats is_delta: false as a snapshot and withdraws every product the response
does not mention — so mislabelling a filtered result empties the shop's shelf.
The code therefore derives both the filter and the flag from one value:
cutoff := posRevisionCutoff(...); zero cutoff ⇒ no filter ⇒ is_delta: false,
non-zero ⇒ filtered ⇒ is_delta: true. There is no path that filters without
setting the flag. Supporting details:
- The revision is an opaque token
loc{id}-{YYYYMMDDTHHMMSSZ}the till stores and hands back. If it is unreadable, malformed, or belongs to a different outlet, the cutoff is zero and you get a full snapshot — failing toward "send everything" is the only safe direction. - The revision only advances on the final page. Mid-pagination it echoes back whatever the till already had. A till that dies half way through a paginated pull must not end up holding a revision claiming it saw pages it never received — those products would be excluded from every future delta, silently, forever.
- The revision stamp is taken one second in the past, so a product written during the same second the query ran cannot land on the wrong side of the next cutoff. Costs one redundant row; cannot lose one.
- "Changed" is one predicate covering three things: the product row, its per-location row (price/availability), or its stock ledger. Stock is in there because a shop's count drifts on every sale rung at another counter.
- Acknowledged limitation, in a comment: a product deleted from
productlocationsleaves no tombstone, so a delta cannot know to withdraw it. Only a full pull collects those — hence "pull without a revision every morning."
(f) Presence is a TTL, not a table. A heartbeat is a fact with an expiry date.
In Postgres it would be ~288k writes/day across a hundred tills plus a reaper job
to mark them dead. A Redis hash with a 90s TTL (three missed 30s heartbeats — "two
would make a GPRS hiccup look like a dead till; five would take 2½ minutes to
notice a real one") ages out for free. The location→terminals SET has no TTL:
it is an index of what exists, not a claim anything is alive. A till whose hash
expired comes back as a stub marked offline with a reason, rather than being
omitted — because the missing till is exactly what someone is looking for. The
write also HDELs fields the till stopped reporting, so a hash never lingers at a
stale battery reading.
Tricky cases the code explicitly handles
| Case | Handling |
|---|---|
| Bill with no id | Rejected — nothing to dedupe on, and it would double on every retry |
| Fractional quantity (1.5 kg onions) vs integer stock column | roundStockQty rounds up — conservative, never records more stock than is physically there. Flagged in-code as a workaround, not a fix |
| Timestamps without a timezone offset (older terminal builds) | Accepted, with a comment stating the instant will be wrong by the offset and is unrecoverable — but the business date is right, which is what daily figures use |
jsonb columns left at Go's zero value |
Forced through posJSON, which emits "null" — an empty string reaches Postgres as invalid JSON and takes the whole bill down |
| Terminal id present on the batch but not the bill | posTerminalFor falls back, trimming first so " " is not mistaken for a real code |
Broker Last Will ({"status":"offline"}) |
Arrives on the same handler and is recorded verbatim — exactly right for a till that lost power |
| Customer registrations replayed | Insert-if-absent, never update — a profile corrected at head office must not be reverted by a till replaying months-old data |
| Loyalty points on the uplink | Deliberately absent from the wire format. Points are derived from the bill stream (idempotent, sees every counter); accepting a till's local balance would make "last till to sync wins" |
Trade-offs, and what they buy
- Bills separate from
orders→ full fidelity, at the cost of every report needing a union. - Payment mode denormalised to "largest tender" for grouping, with the full split
kept verbatim in
paymentsjson→ fast reports, no lost reconciliation data. businessdatedenormalised as aYYYY-MM-DDstring → a day's takings is one indexed equality match instead of a range scan with timezone arithmetic.- Barcode generation:
products.productskucannot be trusted for the till's unique barcode index (in live data, thousands of products share a single SKU value), so a SKU is only used if it looks like a real EAN/UPC — 8–14 digits — and otherwise the product id stands in. Scanning physical barcodes will not work until real ones are populated; the comment says so plainly and notes it starts working with no code change.
2. Transport, concurrency and delivery guarantees
What it is
The same ingest, over two transports (MQTT and HTTP), running under multiple replicas, with backpressure that reaches all the way back to the till.
How it's built
messaging/posmqtt.go (broker client, topic routing, ack publishing) plus
messaging/posworkers.go (a bounded worker pool). Both hand off to the same
PosService the HTTP controller uses — the facade exposes f.PosService()
specifically so a bill cannot behave differently depending on how it arrived.
The approach
Leader election with no coordination service. MQTT has no queue groups — every
subscriber gets every message, so three replicas would each commit the same bill
and publish three acks. The ingest is idempotent so nothing double-counts, but it
is 3× the database work. The fix: a StatefulSet gives pods stable ordinal names,
so ordinal 0 is the elected consumer — no lease, no lock, no extra dependency.
Overridable via POS_MQTT_CONSUMER=always|never; a non-ordinal hostname (bare
container, local dev) is elected, because "a single instance that refused to
consume would be a far more confusing failure."
Backpressure by construction. paho delivers on one goroutine, so naively every bill commits serially — a bill is a full transaction (advisory lock, dedup, row locks, availability, four inserts, commit) at ~10–30 ms, giving 30–100 bills/sec, and a shop-wide backlog after an outage takes minutes. paho can call handlers concurrently, but it spawns without limit — a storm would open a transaction per message, exhaust the connection pool, and stall everything at once.
So: a fixed pool behind a bounded queue, and submit blocks when the queue is
full. That is the point — paho stops acking, the broker's in-flight window fills,
it stops sending, and the till holds its bills and retries. SetOrderMatters(true)
is kept on precisely because single-goroutine delivery is what makes that chain
work; concurrent delivery would let paho keep reading no matter how far behind the
workers were.
Separate pools for bills and heartbeats. A heartbeat is one Redis write; a bill is a transaction. Sharing a queue would delay presence behind a bill backlog, and every till would appear to go dark at the exact moment the system was busiest.
The pool's shutdown race is handled explicitly. A plain select over a
done-channel and the job channel is not enough — once both are ready Go picks at
random, and picking the send panics on a closed channel. So there is an RWMutex
held for reading across the whole of submit, and stop takes the write lock
before closing. The comment works through why this cannot deadlock: workers only
exit once the channel is closed, which happens under the write lock the in-flight
send is holding off. Post-close submissions run inline rather than being
dropped — discarding a bill that already reached you is worse than doing it slowly.
Identity comes from the topic, never the body. topicIdentity parses store and
terminal out of nearle/pos/{store}/{terminal}/{kind}. A till that could name its
own store in a payload could post sales into another shop's books. There is a test
named exactly that: TestABodyCannotOverrideTheTopicIdentity.
Shutdown order is load-bearing. Close() drains the worker pools before
disconnecting, so a bill mid-commit still gets its ack out. Disconnecting first
would strand it — committed here, unacknowledged there, re-sent on the till's next
attempt.
Payloads are copied in wrapHandler because paho reuses its buffer once the
handler returns, and the work now happens after that.
The test suite here is genuinely good: bounded concurrency, blocking-not-dropping, drain-on-stop, idempotent stop, payload copying, ordinal election, and "a failed presence write does not stop the till."
3. Terminal authentication and shop-managed staff
What it is
Before this work, the POS surface was completely open: a till held a store id
typed into a Settings screen and a password compiled into the app, so
store_id=1185 in a URL was enough to read another tenant's catalogue or post
bills into their books. One leaked build opened every tenant on the platform. This
subsystem replaces that with a real session, and adds shop-run staff management on
top.
How it's built
POST /pos/login (the only unguarded route — it's where tokens come from)
│ authname|contactno + password [+ optional configid, location_id]
▼
posAuthRepository.PosLogin
│ reads the SAME app_users rows the web console authenticates against
│ resolves the outlet FROM the user's record — never from the wire
▼
posService.mint → utils.MintPosToken
│ base64url(payload) "." base64url(HMAC-SHA256)
│ claims: uid, tid, lid, rid, cid, trm, iat, exp TTL 30 days
▼
PosSession { token, expires_at, role, can_manage_staff,
tenant + GSTIN + address (for the printed invoice),
locations[] (picker for multi-outlet owners),
staff[] (so the till can trade immediately) }
│
▼
every other /pos/* route → middleware.PosAuth
1. verify signature 2. is the named outlet owned by the token's tenant?
The approach
A signed, self-describing token rather than a session table. Reasoning given in
utils/postoken.go: a till is not a browser. It signs in when the shop opens and
bills for a whole day on a connection that comes and goes, so the credential must
survive reboot, network loss, and an hour in a drawer. A server-side session table
fails that (a till that cannot reach you must still be able to prove who it is when
it returns), and so does a short expiry.
Deliberately not JWT. One issuer, one audience, one algorithm — the header JWT
spends bytes negotiating is a constant. And alg is the source of JWT's
worst-known footgun (alg: none); a format with no algorithm field cannot have
that bug. The MAC is taken over the encoded payload so verification never
re-serialises anything.
Verification order is deliberate: signature first, then expiry. Reading exp
out of an unverified payload would mean taking the attacker's word for when their
own token runs out. Comparison is hmac.Equal (constant time). A token that
verifies but names no outlet is refused, so it cannot be mistaken for one that
authorises everything.
The middleware's second check is the one that matters. A valid token is not a
licence to name any outlet — it is a licence to name your outlets.
requestedLocation reads all three spellings the routes use (store_id,
locationid, location_id) rather than breaking terminals in the field by
normalising, and — crucially — searches the JSON body, not just the query
string, because the two routes that write carry store_id in the batch and
never in the URL. It handles the id being sent quoted or bare, since accepting only
one shape would silently skip the check, and "a skipped check reads exactly like a
passed one."
A migration escape hatch, honestly labelled. POS_AUTH_REQUIRED defaults to
off, because terminals are already in shops billing real customers against
unauthenticated endpoints and flipping enforcement at deploy would stop every one
of them mid-trade. While off, a token that is sent is still fully verified and a
wrong-tenant request is still refused — the flag only governs requests carrying
none.
Two-tier sign-in: password opens the terminal, PIN switches the operator.
- The session token is the security boundary. A four-digit PIN is not:
POST /pos/login/pinsits behind the guard, so guesses are confined to one already-opened outlet's own staff. - PIN login mints a fresh token rather than reusing the presented one, so a cashier taking over from a supervisor drops the supervisor's permissions instead of inheriting them.
- PINs travel in the clear over TLS, and the model file argues the case rather than hiding it: four digits is brute-forceable in microseconds whatever it is wrapped in, so hashing here buys the appearance of strength; meanwhile the terminal salts every PIN with its own random salt, so a hash computed server-side could never be verified there without inventing and maintaining a shared scheme across two codebases. The honest framing: a PIN is shift attribution, not a security boundary.
Schema archaeology, handled rather than wished away:
authnameis not unique inapp_users— live data has the same address twice under one config. Rather thanLIMIT 1(which would let a stranger's account shadow the one a person meant, and on a POS means billing into the wrong tenant), multiple matches are refused with an actionable message.- Inactive accounts are excluded from the match, not matched-then-refused, so a deactivated leaver cannot make a live login ambiguous.
configid(which tenant portal an account belongs to) is asked for by the web console because the browser knows it — but a person at a counter has never seen the number. So it is honoured when sent, inferred when not, and an ambiguous inference is reported rather than guessed.PosConfigidForinfers it from whichever value the tenant's existing accounts most commonly carry.- Staff come from two sources unioned: the purpose-built
tenantstaffstable (a dozen rows on the entire platform) andapp_users.locationid(where staff actually ended up). Reading either alone returns the wrong answer. - Duplicate PINs are dropped from the response, because live data has one PIN shared across many accounts — a shared PIN would attribute a bill to whichever row was read first.
- PINs are bounded 1000–9999 with no leading zero, because
app_users.pinis abigint: "0451" stores as 451, and the cashier types four digits and is refused forever. That costs 1000 of 10000 combinations and buys a PIN that means the same thing in both directions. Obvious PINs (1234, 1111, …) are rejected. useridis left to Postgres's identity column, with a comment explaining the earlier mistake:information_schema.column_defaultis empty for identity columns, which reads like "no default," and a hand-rolled MAX+1 leaves two allocators racing.- Emails go through
NULLIF(?, '')because a unique constraint means a second PIN-only cashier would collide on the empty string, whereas NULLs do not collide in Postgres.
The inversion, applied to people. PosUserRequest has no tenant and no
location field. A supervisor creating staff can only ever create them at their own
outlet, and no field in the struct can say otherwise — the same inversion that
stopped a till naming its own shop. Updates are scoped by tenant and location in
the WHERE clause rather than checked first, so a wrong user id updates zero rows
and is reported, instead of quietly editing another shop's staff. Deactivation is a
status change, never a delete, because bills carry the cashier's name. You cannot
deactivate the account you are signed in as, or the last supervisor could lock the
whole shop out with one tap.
The same service calls are exposed to the web console under /web/tenants/* and
/mob/tenants/* — deliberately the same code, not a parallel implementation,
"because two code paths writing one table is exactly how that stops being true."
The route file itself flags that this half is weaker: the console asserts its
outlet where a terminal proves it, and says these should move behind a session
guard as soon as the console can hold one.
4. The shared order + stock engine
What it is
One transactional path that every sale in the platform goes through, regardless of channel: an app order, a spreadsheet import of historical counter sales, or a POS bill.
How it's built
CreateOrder (app) UploadOfflineSales (spreadsheet) importPosOrder (till)
│ │ │
│ per-bill tx + advisory lock per-bill tx + advisory lock
│ + remarks-based dedup + terminalorderid dedup
▼ ▼ │
┌──────────────────────── createOrderTx ──────────────────────┐ │
│ 0. lockStockRows (FOR UPDATE, sorted, deduped) │ │
│ 1. assertStockAvailable (ledger balance, all lines first) │◄───────┤ (uses the same
│ 1b. priceOrderLines (fill unpriced lines from catalogue) │ │ stockLedger.go
│ 2. nextSequenceNo (UPDATE…RETURNING inside the tx) │ │ helpers directly)
│ 3. insert header, insert lines, recordStockOut per line │ │
│ → syncProductLocationStatus re-derives availability │ │
└─────────────────────────────────────────────────────────────┘ │
│ contract: on failure it has already rolled back; │
│ on success tx is left OPEN so the caller can │
│ include its own dedup guard in the same tx │
▼ ▼
COMMIT pos_orders + productstocks
The approach
Stock is derived, never stored. Availability is SUM(in) − SUM(out) over
productstocks, computed under the row locks. productlocations.status is a
derived cache re-synced from that balance after every movement, in the same
transaction, by the same rule on both the sale side and the receiving side. A
commit message captures the earlier bug this replaced: receiving stock used to
overwrite products.productstatus — a per-product lifecycle column — with an
availability value, destroying the lifecycle state of well over a hundred
products. Availability is a per-outlet fact and a single column on products
cannot express it, since the same product can be stocked at one outlet and empty at
another.
Order-number allocation. nextSequenceNo does read-and-increment in a single
UPDATE … RETURNING inside the caller's transaction. The doc comment is a small
forensic report on the previous implementation: two separate calls on r.db (not
the transaction), so concurrent orders read the same value; a NULL counter made
COALESCE(MAX(x)+1, 1) evaluate NULL+1 = NULL and fall through to a hardcoded
"<tenantid>-1", so a whole cohort of live orders share one id; tenants with
multiple sequence rows hit a GROUP BY where the read kept the first row and the
write updated all of them. The fix pins to MIN(sequenceid), seeds a NULL from the
tenant's existing order count (guaranteed ≥ any id already issued, so recovery never
reissues), and creates the row on first use.
Two rounding conventions, kept apart on purpose. legacyOrderQty (truncate,
floor at 1) is preserved exactly for app orders — changing it would silently
alter stock deduction for every order in production. roundStockQty (ceil) is used
by the POS path. Both are named, tested, and flagged in a comment as something to
reconcile once someone owns the decision. That is the right call: the divergence is
documented rather than papered over.
Deduplication without a natural key. The spreadsheet importer dedupes on
orders.remarks = "OFFLINE:<billno>" under an advisory lock. When the sheet has no
bill number, an FNV-1a hash of the bill's own contents (date, mobile, payment
mode, and each line's product/qty/price) stands in — so re-uploading the same file
is a no-op rather than a double stock deduction. The trade-off is stated: two
genuinely separate identical baskets on the same day with no bill numbers will
collide, and the result names the collision rather than hiding it.
Referential scaffolding. The order-listing query INNER JOINs five tables, so an
imported order with a zero applocationid or customerid would be written
successfully and then be invisible in every screen.
resolveOfflineLocationContext is the single place where "this location belongs to
this tenant" is established (so editing a locationid in a spreadsheet reaches
nothing), and it fills the scaffolding from tenantlocations plus the most recent
real order at that outlet — because tenantlocations carries 0 for
moduleid/partnerid at outlets whose live orders use non-zero values. It refuses
outright rather than writing an order that will never be visible.
Batch semantics. Each bill is its own transaction, so one bad row cannot undo the rest, and the response says exactly which landed, which were duplicates, and which failed with why. Branch context and catalogue are memoised per outlet so a workbook covering six branches does not re-run both queries per bill.
Also here: priceOrderLines fills lines the client sent unpriced from the
merchant's own catalogue, using arithmetic that deliberately matches the offline
importer exactly — gross, minus discount, tax extracted from the landing amount
because shelf prices are MRP (tax inside). One convention for both channels, so the
same basket rings up the same either way. This fixed a real bug where
catalogue-imported products had no per-store price, so real delivered orders
recorded zero revenue.
5. Brand catalogue bridge and the rest of the platform
What it is
A second, isolated Postgres database holds a curated master catalogue of packaged goods, organised one table per brand, with rich metadata (title, description, nutrients, highlights, FSSAI licence, size, variant key, providers). Shop owners browse it and "import" products into their own store catalogue rather than typing them in. Product photography lives in S3-compatible object storage.
How it's built
CatalogueDB (separate conn, may be nil) Object storage (S3-compatible)
brand_<name> tables daily/brands/{brand}/{image_id}/*
│ │
│ catalogueRepository │ db/imagestore.go
│ (table name from a fixed allowlist) │ full LIST → in-memory map,
│ │ swapped atomically, 30-min refresh
└──────────────┬───────────────────────────────┘
▼
productService.ImportCatalogueProduct
│ snapshot into tenant `products` (keyed brand+catalogueid)
│ + link via productlocations (price, stock, status)
▼
nearledb: products / productlocations / productstocks
▼
POS catalogue pull · customer app · order lines
The approach
- Isolation is enforced structurally.
NewCatalogueRepositorytakes the catalogue connection and neverdb.DB. If the catalogue env vars are absent, the connection is simply nil and catalogue endpoints return a normal error — it must never block startup of the main app. Same rule for Redis and the image store: optional dependencies degrade, they do not kill the process. The one dependency that is fatal on misconfiguration is the MQTT broker, and the comment says why: "coming up healthy while every till quietly queues is the worse failure." - Table names cannot be parameterised in SQL, so brand → table goes through a fixed allowlist map. That is the correct pattern.
- The catalogue DSN is built as a URL and percent-encoded via
net/urlrather than akeyword=valueDSN, because that password contains characters the keyword format would misparse as quoting/comment syntax. - GORM's raw scan silently drops slice-kind destination fields, so
text[]columns are cast to text in SQL and parsed in Go. - Cross-brand browsing merges and sorts in Go, with an explicit note that this is
fine at a few hundred rows and should become a
UNION ALLif it grows. - The image store caches a full object listing so no GET ever calls out to S3,
rebuilt from scratch every 30 minutes and swapped under a write lock. A nice
deployment detail: the endpoint is bucket-qualified, and the SDK's
virtual-hosted-style client re-prepends the bucket — so the client is pointed at
the bare region host or requests get addressed to
bucket.bucket.…. - Import is idempotent on
(tenant, brand, catalogueid): re-import updates pricing rather than creating a duplicate product.
The surrounding platform — roughly two thirds of the file count — is a conventional layered Fiber/GORM app: orders, deliveries (rider dispatch, status lifecycle with mirrored timestamps, rider/report summaries), products and stock, tenants and outlets, customers, partners, users, and FCM push. Most of it is CRUD and reporting SQL. The parts worth noting are the repairs, which are documented in-place with the evidence that motivated them:
UpdateDeliveryused to write the parent order withWHERE orderheaderid = ?from a client-supplied field. Clients often omitted it, making it= 0, matching nothing — and GORM reports no error for an update affecting zero rows, so the API answered success while the order silently kept its old status. Hundreds of deliveries were marked delivered against orders still reading pending. Fixed by deriving the link fromdeliveryid(the one field every caller must send) and failing loudly when the row is not there.- The rider push-notification route had been commented out since the initial commit
— the handler, the model, the Firebase service account and the Dockerfile
COPYwere all in place; only the route registration was missing. So every rider push the admin console ever sent returned 404, and riders were assigned deliveries and never told. - New store outlets and their logins were being forced to
InActive, which blocked the spawned manager login before it could ever reach the password-setup screen. - Tenant onboarding created admin users with
configid = 0, which the web login (which queriesconfigid = 1) could never find — permanently unfindable accounts. - Order line items were being silently dropped.
The stack
Language / runtime
- Go 1.24
Web / API
- Fiber v2 (
gofiber/fiber/v2), CORS middleware, customPosAuthmiddleware
Data
- PostgreSQL (primary,
nearledb) via GORM 1.25 +pgx/v5driver — heavily raw SQL, GORM mostly as a connection/scan layer - A second PostgreSQL instance for the brand catalogue (described in code as pgvector)
- Redis 7-family via
go-redis/v9— TTL-based presence, shared with a separate Node/Express service - Postgres features used directly: advisory locks (
pg_advisory_xact_lock),SELECT … FOR UPDATE,UPDATE … RETURNING,jsonb, identity columns
Messaging
- Eclipse Mosquitto over MQTT,
eclipse/paho.mqtt.golangv1.5 — QoS 1, persistent sessions, retained messages, Last Will
Crypto / auth
crypto/hmac+ SHA-256, custom compact token format (not JWT)
Cloud / integrations
- AWS SDK Go v2 S3 client pointed at an S3-compatible object store
- Firebase Cloud Messaging via
golang.org/x/oauth2JWT service-account flow (firebase.google.com/gopresent)
Config / ops
godotenv,spf13/viper,time/tzdata(Asia/Kolkata baked in)- Multi-stage Dockerfile → static binary on Alpine
- Kubernetes StatefulSet (inferred — from the
HOSTNAMEordinal election logic and the-0convention, not from manifests in this repo)
Testing
- Go stdlib
testing, table-driven, with hand-rolled fakes for the MQTT client and the POS service — no mocking framework
Consumers of this API (not in this repo; described in docs and comments): a Flutter POS terminal app with local SQLite, a React/TypeScript merchant console, a consumer mobile app, a rider app, and a separate Node/Express backend sharing the Redis instance.
What's genuinely hard here
1. The ack protocol and idempotency, together. Anyone can write "insert if not exists." What is hard is the discipline that follows from at-least-once delivery when the payload is money that has already changed hands: a duplicate must be reported as success, silence must never be interpreted as acceptance, "reject" must be reserved for permanent faults, transport errors must produce no ack at all, and the whole thing has to hold under a shutdown. The code gets all five right and the reasoning is written down at each decision point. The advisory-lock-then-check pattern — turning a constraint violation into an orderly "already held" — is the specific move to point to.
2. The delta/snapshot invariant in the catalogue pull. The failure mode —
is_delta: false on a filtered response empties a real shop's shelf — is the kind
of bug that only shows up in a store, at a counter, with a queue. Deriving the
filter and the flag from a single value so no code path can set them
inconsistently is the right structural answer, not a defensive check. The
pagination detail (never advance the revision mid-pull) and the one-second cutoff
overlap are both real distributed-systems reasoning: each is a choice about which
direction to fail in, and each picks "redundant work" over "silent permanent data
loss."
3. Backpressure that reaches the physical device. The chain is: bounded queue
blocks → paho's single delivery goroutine stalls → broker's in-flight window fills
→ broker stops sending → till holds its bills. Every link is a deliberate
configuration choice (SetOrderMatters(true) exists solely to preserve link two).
The alternative designs are both worse in ways that are only obvious once you have
reasoned it through: unbounded goroutines exhaust the connection pool and stall
everything at once; dropping work loses a sale you had already accepted. Plus the
pool's close race — recognising that select over done-and-jobs picks randomly and
can panic on a closed channel, and solving it with a read-lock held across the
send — is a genuinely subtle piece of Go concurrency.
4. Retrofitting authorisation onto a live, unauthenticated fleet. The
intellectual move is small and correct: invert the direction of the store id.
It was an input (typed into Settings, believed on the wire); it becomes an output
(derived from the authenticated user's record, sealed under a signature).
Everything else follows — PosUserRequest having no tenant field, the middleware's
tenant-owns-outlet cross-check, the read-the-body-not-just-the-query detail that
closes the hole on the exact routes that write. The rollout strategy (ship the
endpoint, let the fleet adopt, then flip POS_AUTH_REQUIRED) is how you do this
without stopping a hundred shops trading, and the code is honest that the flag is a
temporary state and not a design.
5. Sharing one transactional path across three channels without forking it.
createOrderTx's contract — on failure it has already rolled back; on success the
transaction is left open so the caller can put its own dedup guard inside the same
transaction — is unusual and slightly dangerous, but it is what allows an app
order, a spreadsheet import and a POS bill to share row-locking, availability
checks, ledger writes and sequence allocation. The alternative (three
implementations of the anti-overselling rule) is exactly the drift that produces
"empty in the database, full on the shelf."
6. Making a hostile schema work without a migration. This is unglamorous and it
is a lot of the actual difficulty. Non-unique authname; a bigint PIN column
that eats leading zeros; a roleid of 0 that is not a role; app_roles with six
rows for four roles and most accounts carrying an id that is not in it; a
registrationno column that is really the GSTIN; configid varying per tenant
with no way to look it up; a SKU column where thousands of products share one
value; tax rates in live data that include 3, 7, 15 and −1 when Indian GST only has
0/5/12/18/28. Each of these gets a handler and a written justification measured
against the actual data, rather than a schema change nobody has the appetite to
run. The negative-GST floor is a good example of why this matters: a negative rate
would put negative tax on a bill and a negative figure in a slab on a filed tax
return.
7. The commenting itself. Worth calling out explicitly. Nearly every non-obvious decision carries a comment that states the alternative considered, the failure it prevents, and often the count of live rows that motivated it. Several read as small post-mortems (the sequence-number one, the delivery-status one). That is a real engineering artefact, not decoration — it is what makes the codebase maintainable by someone who was not there.
Flags before publishing any of this
Security issues found while reading
- A Firebase service-account JSON key is committed to the repository and
COPY'd into the Docker image by the Dockerfile. That is a live private key in version control. Rotate it and move it to a secret/volume mount. .envwas tracked until 2026-08-03. The.gitignoresays so itself and notes the database credentials are still in history and the password should be rotated. That does not appear to have happened.- SQL injection in
tenantRepository.CheckTenantByNo— the contact number is string-concatenated into raw SQL. Everything else in the codebase parameterises; this one does not. - Passwords are stored and compared in plaintext platform-wide. There is an
explicit
TODOacknowledging it, correctly noting a POS token minted off a plaintext password is only as good as that column. The comparison is at least constant-time. - The main web/mobile API has no authentication at all.
SECURITY_HANDOFF.mddocuments this: identity comes from client-supplied query params, so requesting another tenant's id returns their data. Eight IDOR endpoints were patched with controller-level scoping guards, but the doc is clear that the root fix has not started. POS_AUTH_REQUIREDdefaults to false, so the POS surface is open unless explicitly enabled — intentional and documented, but worth confirming whether it has been flipped in production.- The
/web/tenants/*and/mob/tenants/*staff routes mint till credentials on unauthenticated requests. The route file flags this itself. - CORS is
AllowOrigins: "*"withAllowCredentials: true— that combination is rejected by browsers and is a smell either way.
Commercially sensitive — genericise before this goes public
- Named brand partners in the catalogue allowlist (six FMCG brands, some of them major). That is a partnership roster.
- The production hostname and the deployed API base path, which appear in the
proof scripts under
scratch/. - Live customer/tenant identifiers — the scratch scripts and doc examples contain real tenant ids, location ids, store names and at least one real email address. All of them have been kept out of this document.
- Data-quality statistics about the live estate (row counts, how many products share a SKU, how many accounts use a given PIN, duplicate-order-id counts, desynced-delivery counts). These make the writeup much more convincing, but they are an unflattering audit of a client's production data. Keep the reasoning and drop or round the numbers — "thousands of products shared a single SKU value" carries the point without publishing the audit. Exact figures have been rounded or removed here already.
- The terminal sync contract itself (topic structure, ack semantics, revision format). It is the interface between two of their products; the techniques are portfolio-safe, the exact wire protocol less so.
Inferred rather than read directly
Deployment as a Kubernetes StatefulSet (from the ordinal election logic, not from manifests); the existence and behaviour of the Flutter till app, the React console, the consumer/rider apps and the Express backend (from docs and comments — none are in this repo); and that the catalogue database uses pgvector (asserted in comments, but nothing in this repo issues a vector query).