Add pending POS scratch checks and portfolio notes

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>
This commit is contained in:
2026-09-15 17:04:34 +05:30
parent 72907dae74
commit 369e9fc7b4
3 changed files with 1211 additions and 0 deletions

778
docs/PORTFOLIO.md Normal file
View File

@@ -0,0 +1,778 @@
# 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's `orders` table. A bill carries a cashier,
a terminal id, a rounding adjustment, promo campaigns, loyalty movement and a
payment split across several tenders; `orders` has nowhere to put any of that.
The stated cost of the split is that every revenue query has to union both —
which was done, in `orderRepository.posRevenue` and `posSalesTotals`.
- **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's `delivery:*` / `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
`productlocations` leaves 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 `HDEL`s 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.
- `businessdate` denormalised as a `YYYY-MM-DD` string → a day's takings is one
indexed equality match instead of a range scan with timezone arithmetic.
- Barcode generation: `products.productsku` cannot 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/pin` sits *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:**
- `authname` is not unique in `app_users` — live data has the same address twice
under one config. Rather than `LIMIT 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. `PosConfigidFor` infers it from
whichever value the tenant's existing accounts most commonly carry.
- Staff come from **two** sources unioned: the purpose-built `tenantstaffs` table
(a dozen rows on the entire platform) and `app_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.pin` is a
`bigint`: "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.
- `userid` is left to Postgres's identity column, with a comment explaining the
earlier mistake: `information_schema.column_default` is 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.** `NewCatalogueRepository` takes the
catalogue connection and *never* `db.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/url` rather than
a `keyword=value` DSN, 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 ALL` if 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:
- `UpdateDelivery` used to write the parent order with `WHERE 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 from `deliveryid` (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 `COPY`
were 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 queries `configid = 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, custom `PosAuth` middleware
**Data**
- PostgreSQL (primary, `nearledb`) via GORM 1.25 + `pgx/v5` driver — 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.golang` v1.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/oauth2` JWT service-account flow
(`firebase.google.com/go` present)
**Config / ops**
- `godotenv`, `spf13/viper`, `time/tzdata` (Asia/Kolkata baked in)
- Multi-stage Dockerfile → static binary on Alpine
- Kubernetes StatefulSet *(inferred — from the `HOSTNAME` ordinal election logic
and the `-0` convention, 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
1. **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.
2. **`.env` was tracked until 2026-08-03.** The `.gitignore` says so itself and
notes the database credentials are still in history and the password should be
rotated. That does not appear to have happened.
3. **SQL injection in `tenantRepository.CheckTenantByNo`** — the contact number is
string-concatenated into raw SQL. Everything else in the codebase
parameterises; this one does not.
4. **Passwords are stored and compared in plaintext platform-wide.** There is an
explicit `TODO` acknowledging 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.
5. **The main web/mobile API has no authentication at all.**
`SECURITY_HANDOFF.md` documents 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.
6. **`POS_AUTH_REQUIRED` defaults to false**, so the POS surface is open unless
explicitly enabled — intentional and documented, but worth confirming whether it
has been flipped in production.
7. **The `/web/tenants/*` and `/mob/tenants/*` staff routes mint till credentials
on unauthenticated requests.** The route file flags this itself.
8. **CORS is `AllowOrigins: "*"` with `AllowCredentials: 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).

View File

@@ -0,0 +1,267 @@
// Does the mobile-number-and-PIN sign-in actually work against the live data?
//
// Picks a supervisor and a cashier that already have both halves of the
// credential, prints them so they can be typed into a terminal, then runs the
// real repository and service — the same code path the HTTP handler calls — and
// reports what came back.
//
// Read-only. PosLogin issues SELECTs and mints a token in memory; nothing here
// writes, and the token is not persisted anywhere by design.
//
// Numbers and PINs are masked unless -show is passed. They belong to real
// people at real shops, and the default should not be to print them into
// whatever is capturing this program's output.
//
// go run ./scratch/poslivecheck # picks a ready pair, masked
// go run ./scratch/poslivecheck -show # prints the credentials
// go run ./scratch/poslivecheck -show 1087 1137 # ...at a named outlet
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"strconv"
"strings"
"nearle/models"
"nearle/repositories"
"nearle/services"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type account struct {
Userid int
Tenantid int
Locationid int
Roleid int
Fullname string
Contactno string
Pin int64
}
func main() {
_ = godotenv.Load()
if strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET")) == "" {
log.Fatal("POS_TOKEN_SECRET is not set; sign-in mints a token and will fail without it")
}
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
log.Fatal(err)
}
// Only accounts holding both halves are candidates. An account missing
// either cannot sign in at all, and picking one would prove nothing except
// that the rejection works.
where := `COALESCE(roleid,0) = ?
AND COALESCE(pin,0) BETWEEN 1000 AND 9999
AND TRIM(COALESCE(contactno,'')) <> ''
AND LOWER(COALESCE(status,'active')) <> 'inactive'`
args := []interface{}{}
// -show opts into printing the credentials themselves.
show := false
rest := []string{}
for _, arg := range os.Args[1:] {
if arg == "-show" || arg == "--show" {
show = true
continue
}
rest = append(rest, arg)
}
if len(rest) > 1 {
tenantID, _ := strconv.Atoi(rest[0])
locationID, _ := strconv.Atoi(rest[1])
where += ` AND tenantid = ? AND locationid = ?`
args = append(args, tenantID, locationID)
}
pick := func(roleID int) *account {
var a account
params := append([]interface{}{roleID}, args...)
err := db.Raw(`
SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
COALESCE(roleid,0) AS roleid,
TRIM(CONCAT(COALESCE(firstname,''),' ',COALESCE(lastname,''))) AS fullname,
COALESCE(contactno,'') AS contactno, COALESCE(pin,0) AS pin
FROM app_users WHERE `+where+` ORDER BY userid LIMIT 1`, params...).Scan(&a).Error
if err != nil || a.Userid == 0 {
return nil
}
return &a
}
supervisor := pick(models.PosRoleSupervisor)
cashier := pick(models.PosRoleCashier)
fmt.Println("Accounts that can sign in today")
fmt.Println(strings.Repeat("-", 78))
for _, pair := range []struct {
label string
a *account
}{{"supervisor", supervisor}, {"cashier", cashier}} {
a := pair.a
if a == nil {
fmt.Printf(" %-11s none — no account of this role has both a number and a PIN\n", pair.label)
continue
}
fmt.Printf(" %-11s userid %-6d tenant %-6d outlet %-6d %s\n",
pair.label, a.Userid, a.Tenantid, a.Locationid, a.Fullname)
fmt.Printf(" %-11s mobile %s PIN %s\n\n", "",
mask(a.Contactno, show), maskPin(a.Pin, show))
}
if !show {
fmt.Println(" (masked — re-run with -show to print them)")
}
if supervisor == nil && cashier == nil {
log.Fatal("nothing to test with")
}
repo := repositories.NewPosRepository(db)
svc := services.NewPosService(repo, nil)
fmt.Println("\nSigning in (real service, live data)")
fmt.Println(strings.Repeat("-", 78))
pass, fail := 0, 0
check := func(name string, ok bool, detail string) {
if ok {
pass++
fmt.Printf(" PASS %-46s %s\n", name, detail)
return
}
fail++
fmt.Printf(" FAIL %-46s %s\n", name, detail)
}
signIn := func(label string, a *account) *models.PosSession {
if a == nil {
return nil
}
session, err := svc.Login(models.PosLoginRequest{
Contactno: a.Contactno,
Pin: strconv.FormatInt(a.Pin, 10),
})
if err != nil {
check(label+" signs in", false, err.Error())
return nil
}
check(label+" signs in", true, fmt.Sprintf(
"%s at %s (outlet %d), can_manage_staff=%v",
session.Role, session.Locationname, session.Locationid, session.Canmanagestaff))
check(label+" gets a token", session.Token != "",
fmt.Sprintf("%d chars, expires %s", len(session.Token), session.Expiresat))
return session
}
supSession := signIn("supervisor", supervisor)
cashSession := signIn("cashier", cashier)
if supSession != nil {
check("supervisor can manage staff", supSession.Canmanagestaff, "role 7 grants it")
}
if cashSession != nil {
check("cashier cannot manage staff", !cashSession.Canmanagestaff, "role 8 does not")
}
// The response must not carry anyone's PIN — the whole point of the change
// that removed staff[].pin.
//
// Checked against the serialised JSON, not the Go struct. PosStaffMember.Pin
// is still *populated* — the query needs it to drop two people sharing a PIN
// — and is kept off the wire by `json:"-"`. Asserting on the struct field
// tests the wrong layer and fails a correct implementation.
if supSession != nil {
encoded, merr := json.Marshal(supSession)
check("session serialises", merr == nil, errText(merr))
if merr == nil {
check("no PIN travels in the session", !strings.Contains(string(encoded), `"pin"`),
fmt.Sprintf("%d staff listed, %d bytes of JSON", len(supSession.Staff), len(encoded)))
}
}
// A number typed the way a person actually types it.
if supervisor != nil && len(supervisor.Contactno) == 10 {
for label, typed := range map[string]string{
"+91 with spaces": "+91 " + supervisor.Contactno[:5] + " " + supervisor.Contactno[5:],
"leading zero": "0" + supervisor.Contactno,
"bare ten digits": supervisor.Contactno,
} {
_, err := svc.Login(models.PosLoginRequest{
Contactno: typed, Pin: strconv.FormatInt(supervisor.Pin, 10),
})
check("number accepted as typed", err == nil, label)
}
}
// And the refusals.
if supervisor != nil {
wrong := supervisor.Pin + 1
if wrong > 9999 {
wrong = 1000
}
_, err := svc.Login(models.PosLoginRequest{
Contactno: supervisor.Contactno, Pin: strconv.FormatInt(wrong, 10),
})
check("a wrong PIN is refused", err != nil, errText(err))
}
_, err = svc.Login(models.PosLoginRequest{Contactno: "9999999999", Pin: "4821"})
check("an unknown number is refused", err != nil, errText(err))
// Switching operator at an open terminal, which is what the till does when
// a colleague takes over.
if supSession != nil && cashier != nil && cashier.Locationid == supSession.Locationid {
switched, err := repo.PosLoginByPin(supSession.Tenantid, supSession.Locationid,
strconv.FormatInt(cashier.Pin, 10))
if err != nil {
check("operator switch by PIN", false, err.Error())
} else {
check("operator switch by PIN", true,
fmt.Sprintf("now %s, can_manage_staff=%v", switched.Role, switched.Canmanagestaff))
}
}
fmt.Printf("\n%d passed, %d failed\n", pass, fail)
if fail > 0 {
os.Exit(1)
}
}
// mask shows enough of a number to recognise the account, not enough to sign in
// as it.
func mask(number string, show bool) string {
if show {
return number
}
if len(number) != 10 {
return strings.Repeat("*", len(number))
}
return number[:2] + "******" + number[8:]
}
func maskPin(pin int64, show bool) string {
if show {
return strconv.FormatInt(pin, 10)
}
return "****"
}
func errText(err error) string {
if err == nil {
return "no error"
}
return err.Error()
}

View File

@@ -0,0 +1,166 @@
// Can the accounts that may open a till actually complete the new sign-in?
//
// Read-only, and deliberately prints no numbers and no PINs — only whether each
// account has one and whether the login would find it.
//
// The question this answers is narrower than "does it have a number". The login
// matches `LOWER(TRIM(contactno)) = LOWER(TRIM($1))` where $1 has already been
// reduced to ten digits, so the comparison is exact: a row holding
// "+91 98765 43210" is invisible to somebody typing the same number, because
// only one side of the comparison gets normalised.
//
// go run ./scratch/posloginready
package main
import (
"fmt"
"log"
"os"
"strings"
"nearle/models"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type row struct {
Userid int
Tenantid int
Locationid int
Roleid int
Contactno string
Pin int64
Status string
}
// normalise mirrors repositories.normalisePosPhone, which is unexported.
func normalise(raw string) string {
digits := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, raw)
if len(digits) == 12 && strings.HasPrefix(digits, "91") {
digits = digits[2:]
} else if len(digits) == 11 && strings.HasPrefix(digits, "0") {
digits = digits[1:]
}
if len(digits) != 10 {
return ""
}
return digits
}
func main() {
_ = godotenv.Load()
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
log.Fatal(err)
}
var rows []row
if err := db.Raw(`
SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
COALESCE(roleid,0) AS roleid, COALESCE(contactno,'') AS contactno,
COALESCE(pin,0) AS pin, COALESCE(status,'') AS status
FROM app_users
WHERE COALESCE(roleid,0) IN (?, ?)
ORDER BY tenantid, locationid, userid`,
models.PosRoleSupervisor, models.PosRoleCashier).Scan(&rows).Error; err != nil {
log.Fatal(err)
}
// What the login would see. Only active till accounts are candidates, and a
// number matching more than one of them is refused outright.
byPhone := map[string][]int{}
for _, r := range rows {
if strings.EqualFold(strings.TrimSpace(r.Status), "inactive") {
continue
}
if stored := strings.TrimSpace(r.Contactno); stored != "" {
byPhone[strings.ToLower(stored)] = append(byPhone[strings.ToLower(stored)], r.Userid)
}
}
fmt.Printf("till accounts (roleid %d/%d): %d\n\n", models.PosRoleSupervisor, models.PosRoleCashier, len(rows))
fmt.Printf("%-8s %-8s %-9s %-5s %-9s %-8s %-7s %s\n",
"userid", "tenant", "location", "role", "status", "number", "pin", "can sign in?")
fmt.Println(strings.Repeat("-", 86))
var ready, noPhone, unnormalised, noPin, ambiguous, inactive int
for _, r := range rows {
stored := strings.TrimSpace(r.Contactno)
norm := normalise(stored)
phoneState := "missing"
switch {
case stored == "":
phoneState = "missing"
case norm == "":
phoneState = "unusable"
case norm != stored:
phoneState = "STORED RAW"
default:
phoneState = "ok"
}
pinState := "missing"
if r.Pin >= 1000 && r.Pin <= 9999 {
pinState = "ok"
} else if r.Pin != 0 {
pinState = "unusable"
}
verdict := "yes"
switch {
case strings.EqualFold(r.Status, "inactive"):
verdict, inactive = "no — inactive", inactive+1
case stored == "":
verdict, noPhone = "no — no number", noPhone+1
case norm == "":
verdict, noPhone = "no — number unusable", noPhone+1
case norm != stored:
// The one that looks fine in the console and fails at the counter.
verdict, unnormalised = "NO — number stored unnormalised", unnormalised+1
case pinState != "ok":
verdict, noPin = "no — no usable PIN", noPin+1
case len(byPhone[strings.ToLower(stored)]) > 1:
verdict, ambiguous = "NO — number shared with another till account", ambiguous+1
default:
ready++
}
fmt.Printf("%-8d %-8d %-9d %-5d %-9s %-8s %-7s %s\n",
r.Userid, r.Tenantid, r.Locationid, r.Roleid, r.Status, phoneState, pinState, verdict)
}
fmt.Printf("\nready to sign in with number + PIN : %d of %d\n", ready, len(rows))
fmt.Printf(" blocked, no/unusable number : %d\n", noPhone)
fmt.Printf(" blocked, number stored raw : %d\n", unnormalised)
fmt.Printf(" blocked, no usable PIN : %d\n", noPin)
fmt.Printf(" blocked, number not unique : %d\n", ambiguous)
fmt.Printf(" inactive : %d\n", inactive)
// Cross-tenant collisions are the failure creation cannot prevent:
// posPhoneTaken scopes uniqueness to one tenant, the login does not scope at
// all, so two tenants may each hold a number that neither can then use.
fmt.Println("\nnumbers shared by more than one active till account:")
found := false
for _, users := range byPhone {
if len(users) > 1 {
found = true
fmt.Printf(" userids %v — all refused with \"more than one account uses these sign-in details\"\n", users)
}
}
if !found {
fmt.Println(" none")
}
}