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:
778
docs/PORTFOLIO.md
Normal file
778
docs/PORTFOLIO.md
Normal 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).
|
||||
267
scratch/poslivecheck/main.go
Normal file
267
scratch/poslivecheck/main.go
Normal 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()
|
||||
}
|
||||
166
scratch/posloginready/main.go
Normal file
166
scratch/posloginready/main.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user