Compare commits

..

15 Commits

Author SHA1 Message Date
Suriya
f7895d3ccf Correct what /posroles tells a console about each role
The supervisor blurb still said "Also signs into the app", which was true when it
was written and is now the opposite of true: a till account has no Nearle Daily
login at all. A console showing that text would be telling a store admin the one
thing about these roles they most need not to believe.

The cashier blurb said "Billing only", which understated it in the other
direction — a cashier now has their own username and password, so a shop can
open without a supervisor standing there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:14:10 +05:30
Suriya
9e9401215d Give a cashier their own till login, not just a PIN behind a supervisor
A PIN cannot open a closed terminal. The PIN route needs a session that already
exists, so a PIN-only account works only while somebody else is standing there
to unlock the till first. For a supervisor that was an outright deadlock and was
fixed last commit. For a cashier it is subtler and just as wrong: the shop
cannot open until two people have arrived, and whoever gets in at seven is as
often the cashier as the supervisor.

So every till account now gets a username and a password, and the role decides
the shell rather than the credential deciding it. A cashier signs in exactly the
way a supervisor does and is still held to billing only, because that comes from
roleid 8 and not from how they got in.

The earlier reasoning — that a second password is one more credential to leak
for no capability gained — was measuring the wrong thing. It counted the cost of
the credential and not the cost of the shop that cannot open without one.

CreatePosUser generates both when the request omits them, so provisioning is one
call per person and nobody has to invent a naming scheme. An explicit value
always wins. A generated name that collides walks to the next free one, because
a second cashier at one counter is ordinary rather than an error; a name the
caller supplied is refused instead, because silently signing somebody in as
another person's address is worse than a message. Uniqueness is checked against
authname and email together, since the insert writes the same value to both and
app_users_email_unique would otherwise fail the transaction rather than return
something anyone can act on.

The password comes back exactly once, in the creation response. Listing till
users still reports only has_password, so an admin who loses it reissues rather
than looks it up — the right shape even while the column behind it is plaintext.

The domain is deliberately unroutable. These are till credentials, never a
mailbox, and an address that looks deliverable invites somebody to try sending a
reset to it.

Verified against live rows by scratch/posseparation, which now checks the
cashier path too: cashier.1185@pos.nearle.in opens a closed terminal alone and
comes back can_manage_staff=false. All five outlets that stock products have
both accounts, each proved by an actual sign-in.

Also drops a stray `print(queryBuilder.String())` from GetAllUsers, which was
writing the whole SQL statement to stderr on every call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:04:24 +05:30
Suriya
c0a7fbc1b1 Stop the till and Nearle Daily from sharing accounts
app_users is the only thing the two products have in common, and the code was
treating it as though it were the whole relationship. Both directions leaked.

Back-office roles were leaking into the till. PosRoleCanManageStaff returned
true for roleid 1 to 6, on the reasoning that somebody who already administers a
shop from a browser is not made less privileged by standing at the counter. That
sounds fine and is wrong: measured against live data it handed till-supervisor
powers to 68 accounts, 59 of them Nearle Daily Super admins, not one of whom is
the administrator of anybody's POS. Meanwhile the actual shop accounts carry
roleid 0 and were refused, so the mapping was backwards from intent in both
halves at once.

Till accounts were leaking into the application. GetStaffs is WHERE tenantid
with no role filter, so a Counter Cashier appeared in the tenant staff list
beside the delivery riders — a row every action on that page would fail against,
since a cashier has no app login, no rider shift and no back-office screen.

So: eligibility for a till is now granted explicitly by provisioning a
Supervisor or a Cashier, never inherited from a back-office role, and roles 7
and 8 are excluded from every Nearle Daily lookup. The exclusion lives in the
queries rather than in a check after them, because a check bolted on afterwards
has to be repeated at six call sites and is one edit away from being forgotten
at one of them — and that one would be the hole. A till account is not rejected
by the app login; it is not found.

Two things this surfaced that were not visible before.

A Supervisor could not open a till. PIN sign-in needs a session that already
exists, so once back-office roles were refused, an outlet whose only POS
accounts were PIN-only had no way in at all. Supervisors are now provisioned
with a username and password as well as a PIN; cashiers deliberately get neither,
because they sign on at a counter somebody has already opened and a second
password would be one more credential to leak for no capability gained.

UpdatePosUser silently dropped authname. It wrote the password, reported
success, and left the account unreachable by either lookup — the failure
surfaced at a counter as "not recognised" rather than on the screen that caused
it. Contactno had the same gap.

Verified against live rows rather than asserted, by scratch/posseparation: a
provisioned supervisor signs in and gets the supervisor shell; five real
back-office accounts including Super admins are refused; the supervisor is
invisible to applogin, tenant weblogin and the password-setup lookup; and no
till account appears in getallusers, while asking for role 7 by name still
returns them so the console can read its own people.

All five outlets that stock products now have a Supervisor and a Cashier.

Also moves the loose markdown into docs/, which was already staged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:52:23 +05:30
Suriya
f5e16b54cc Add a read-only report of who can actually open a till at an outlet
Answers the question a shop asks on day one and nothing in the product could:
what do I type into the terminal. It separates the two credentials because they
are not interchangeable — a password opens a closed terminal and the account
decides which shell it opens, a PIN only switches operator on one already open —
and it prints the shell each account would land in rather than the raw roleid,
since roleid 0 is not in app_roles and reads as nothing at all.

`top` ranks outlets by products actually stocked *and* filters to ones somebody
can sign in to. That filter is the point: the best-stocked outlets on the
platform — Dilse at 471 products, Ninhao at 286 — have no account with a
password, so a demo pointed at either opens a till nobody can unlock.

Counts products through productlocations rather than per tenant, because a
tenant with a full catalogue can still have an outlet stocking none of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:26:10 +05:30
Suriya
cd2459dbb6 Let an admin create till staff from the console, through the same code
An admin sets a shop up from a browser; a supervisor adds a cashier at the
counter. Both had to be possible, and only the second one was.

So the console gets createposuser / updateposuser / getposusers /
deleteposuser, under both /v1/web/tenants and /v1/mob/tenants — calling the
same service methods `/pos/users` calls. Not a parallel implementation: a
supervisor created from a browser is the same row, with the same PIN rules, the
same duplicate check and the same identity-column allocation, as one created at
a till. Two paths writing one table is precisely how the two stop matching, and
this codebase already had that happen once.

configid is inferred rather than asked for. It is a number nobody looks up, it
varies per tenant — 1087's accounts are spread across 1, 6 and 15 — and getting
it wrong creates somebody who cannot sign into the portal their colleagues use
and is invisible to half the platform's queries.

/posroles is served rather than left to the console to hardcode. A console that
knew supervisor was 7 would be wrong the day that changed and would have no way
to find out.

The outlet is the real difference between the two doors. A terminal proves it
with a signed token; the console asserts it, and is checked against the tenant
before anything is written. That is weaker, and it is worth being plain about:
these mint till credentials on an unauthenticated request, exactly like every
other route in the /v1/web and /v1/mob groups, because there is no auth
middleware on the web API at all. Documented as the weakest point in the design
and flagged to move behind a session guard once the console can hold one. The
terminal routes are untouched by it.

Proven in a rolled-back transaction against live data: the console creates a
supervisor at 1135, that supervisor signs in by PIN with can_manage_staff true,
the till's /pos/staff sees them alongside the two created at the counter, and
0451, 1234 and a duplicate PIN are each refused with the same message the
terminal gives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:42:27 +05:30
Suriya
6a62dbb9f3 Hold web-created staff to the same rules the till applies
Two paths write `app_users`: the console's `tenants/createstaff`, and the
terminal's `/pos/users`. Only one of them checked anything.

`createstaff` wrote whatever it was handed. A cashier could be created there
with PIN "0451" — which a bigint column stores as 451 — and would then type four
digits at the counter and be refused for ever, with nothing on either screen to
explain it. Or with 1234, which live data already has on eleven accounts. Or
with a PIN somebody at the same outlet already had, which attributes a bill to
whichever row is read first. Or with no way to sign in at all.

None of that surfaced where it was caused. It surfaced at a counter, days later,
as "the new person cannot log in".

So the rules move into `ValidateStaffUser`, and both paths use it: a name, a
role that is actually a role, a PIN the schema can hold and nobody guesses
first, and at least one way to sign in. The duplicate-PIN check runs too, when
the row names an outlet.

The handler also stops answering 500 with a body claiming 409. Every one of
these is something the person filling in the form can fix, so it is a 400
carrying the reason.

`GetStaffs` now returns `rolename` alongside `roleid`, so a console can show
"Supervisor" without mapping ids itself — `app_roles` has six rows for four
back-office roles and most accounts carry an id absent from it, so any mapping
written client-side would be wrong.

This is what makes the two role systems one. A supervisor or cashier created
from the web behaves at the till exactly like one created at the till, because
there is now a single definition of what those are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:37:28 +05:30
Suriya
f343f4e86e Let the identity column allocate userid, instead of computing it
`app_users.userid` is a `GENERATED BY DEFAULT AS IDENTITY` column. It did not
look like one: `information_schema.columns.column_default` is empty for identity
columns, and reading that as "no default at all" is how this came to compute its
own id with MAX+1.

That worked, and quietly did the wrong thing. An explicit id does not advance
the sequence, so two allocators ended up running in parallel — the sequence sat
at 1447 while MAX(userid) had reached 9189. They cannot collide today, because
almost nothing occupies the range between, but they converge on every insert and
the first collision would be a primary key violation on a live sign-up.

The insert now omits userid and reads it back with RETURNING. The advisory lock
stays, because it was never about the id: two supervisors adding staff at the
same instant could both find a PIN free and both take it, and a duplicate PIN
attributes a bill to whichever row is read first.

Also documents why the email columns go through NULLIF. `app_users_email_unique`
is real, and a second cashier created without an email would otherwise collide
on the empty string — while NULLs do not collide in Postgres. A cashier who
signs in by PIN alone has no email, which is the common case rather than the
edge one.

Verified in a rolled-back transaction against live data: creation allocates
1448, the sequence advances 1447 -> 1448, a second emailless user is accepted,
and a duplicate PIN is still refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:30:18 +05:30
Suriya
4b27b84b1f Let a shop run its own counter: supervisor and cashier, created from the till
A shop had no way to add the people who work in it. The terminal fell back to
three names and three PINs compiled into the app — the same three on every
install — because there was nothing for it to fall back *from*.

Two roles now exist in `app_roles`: Supervisor (7) runs the terminal and creates
staff, Cashier (8) bills. Fixed ids, written by hand, because that table has no
sequence and every id in it was assigned the same way. configid is left NULL
rather than duplicated per portal: a till is a till whichever portal a tenant
uses, and Admin already appears twice in that table for exactly that reason.

`/pos/users` is CRUD over them, and `/pos/login/pin` signs a cashier on at a
terminal a supervisor has already opened.

The rule every one of these follows: **tenant and outlet come from the caller's
token, never from the request.** There is no location field on the create body
to get wrong. A supervisor at Selvapuram cannot create staff at R mart, for the
same reason a till cannot bill into another shop's books — it is the same
inversion applied to people instead of sales.

PIN sign-in is deliberately behind the guard. Four digits is ten thousand
guesses, which is no barrier to an anonymous caller; requiring a session means a
real password opened the terminal first and the guesses are confined to one
outlet's own staff. The session it mints is fresh rather than derived, so a
cashier taking over from a supervisor drops their permissions instead of
inheriting them.

Three things the schema forced:

- A PIN cannot start with zero. `app_users.pin` is a bigint, so "0451" stores as
  451 and reads back as three digits — a cashier would type four and be refused
  for ever. Live data already holds one such account. Rendering refuses to show
  a PIN it cannot represent, rather than showing a short one nobody can type.
- `app_users` has no sequence either, so the next id is read and written inside
  one transaction behind an advisory lock. Two supervisors creating staff at the
  same moment would otherwise compute the same id and one insert would lose.
- 1234, 1111 and friends are refused outright. Live data has 1234 on eleven
  accounts and 1111 on nine.

Proven against outlet 1135, which had zero staff and was the reason the built-in
PINs were still load-bearing:

    created 9188  Store Supervisor  Supervisor  can_manage_staff=true
    created 9189  Counter Cashier   Cashier     can_manage_staff=false
    /pos/staff now returns 2        an unknown PIN is refused

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:22:16 +05:30
Suriya
c696ec3e79 Document terminal sign-in, and prove it against the deployed API
POS_LOGIN.md is for whoever builds and tests the till: the flow in the order it
has to happen, the three endpoints with real request and response shapes, every
error code with its verbatim message, the multi-outlet picker rules, and how
staff and PINs are meant to be handled.

Three things in it are the ones people will otherwise get wrong. `store_id`
comes out of the login response and is never typed by anyone — that is the whole
change. `staff` is usually empty, including at the outlet this build ships
pointed at, so an empty list has to be a no-op and not a wipe. And enforcement
is currently off, which means an untokened request still works today but a token
that *is* sent is still fully checked.

scratch/liveloginproof signs in against the live endpoint with a password read
out of the database — never printed, never passed on a command line where it
would land in a shell history — and then checks the token opens what it should
and refuses what it should not. The token is truncated in its output for the
same reason: it is a bearer credential for a whole trading day.

Run against v1.3.98 in production:

    POST /login                     200   token minted, store_id 1135 resolved
    GET  /session                   200
    GET  /staff                     200
    GET  /catalogue?store_id=1135   200
    GET  /catalogue?store_id=1185   403   this session cannot reach outlet 1185
    POST /health                    202

The 403 is the one worth keeping: a valid token, refused at another tenant's
outlet. That is the hole this work existed to close, shut on live traffic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:26:52 +05:30
Suriya
c4dfcd5387 Serve a shop's own staff to the till, so the built-in PINs can retire
The terminal shipped with three names and three PINs compiled into it. Same
three on every install, readable by anyone with the APK, and permanent —
nothing anywhere could replace them.

`/pos/staff` answers with the people the back office says may ring a bill at an
outlet, and the same list rides down with the session so a till is ready to
trade the moment it signs in. The terminal writes them over its own and
deactivates whatever it had, which is what actually kills the seeded logins.

Two sources are unioned because the schema has two and neither is complete.
`tenantstaffs` is the table built for this and holds 12 rows on the entire
platform; `app_users.locationid` is where staff actually ended up. Either alone
returns nothing for almost every shop.

The endpoint takes no location parameter. The answer carries PINs, so the
outlet comes from the caller's token and a request without one is refused
whatever POS_AUTH_REQUIRED says — a till must not be able to ask who works at
the shop next door.

Rows with no PIN are dropped rather than sent: a name on screen nobody can sign
in as reads as a broken terminal rather than as an unfinished setup. Duplicate
PINs are dropped too, keeping the first — live data has 1234 on eleven accounts
and 1111 on nine, and two people sharing one would make the till attribute a
bill to whichever row it checked first.

The PIN travels in the clear over TLS, deliberately. Four digits are
brute-forceable in microseconds however they are wrapped, so hashing here would
buy the appearance of strength and not the substance — while costing something
real, since the terminal salts every PIN with its own salt before storing it
and could never verify a hash computed here. A PIN is shift attribution, not a
security boundary; the boundary is the session token.

Verified against live data, and it says the fallback still matters: outlet 1135
— the one the POS actually uses — has zero staff, and the only staff row found
anywhere is a delivery rider on PIN 1111.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:01:49 +05:30
Suriya
12165d5e58 Give the POS a real sign-in, and stop believing the store id on the wire
The POS surface was open. A till named its own outlet — `store_id` in a query
or in an ingest batch — and was believed, so one number changed in Settings
read another tenant's catalogue or posted bills into their books. There was no
middleware in the codebase at all, and the `JWT_SECRET_KEY` in the config was
read and never used.

Products were never mis-scoped: `resolvePosStore` already derived the tenant
from the location and the catalogue query already filtered on both. The tenant
was never taken from the wire. What was missing was any check that the caller
was entitled to the location they named.

So the outlet now comes *out* of a sign-in rather than going *in* from the
till. `POST /pos/login` authenticates against the same `app_users` rows the web
console uses — one account store, so deactivating a leaver closes both doors —
and answers with the outlets that account may reach, sealed in an HMAC-SHA256
token the terminal cannot edit.

Two checks then guard everything else, in order: the token verifies, and the
outlet named in the request belongs to the token's tenant. The second is the
one that matters — a valid token is a licence to name *your* outlets, not any.

Notes on the awkward parts:

- The guard reads the outlet from the body as well as the query. The two routes
  that write carry `store_id` in a JSON batch and never in the URL, so a
  query-only check would have left exactly the dangerous call unguarded.
- Three spellings of one thing survive — `store_id`, `locationid`,
  `location_id`. All three are read rather than normalised, because renaming
  them breaks terminals already in the field.
- `POS_AUTH_REQUIRED` defaults to false. Tills are billing real customers
  against the open endpoints right now and enforcing at deploy would stop every
  one mid-trade. A token is still verified when sent, and a wrong-tenant token
  still refused; the flag only governs requests carrying none.
- `POS_TOKEN_SECRET` has no baked-in fallback and fails loudly. A development
  secret in source is the same as no signature at all.
- `configid` is inferred when the till does not send it, because a person at a
  counter has no way to know theirs. `authname` is not unique in this schema —
  live data has one address twice under one configid — so an ambiguous match is
  refused rather than resolved by LIMIT 1, which could bill into the wrong
  tenant's books.

Verified against live data: 58 accounts across 34 tenants can open a till, an
account pinned to a location resolves to it alone, a tenant-level account gets
all six of its outlets, and a cross-tenant outlet request is refused.

Passwords are still plaintext platform-wide. Flagged at the comparison site;
fixing it is a migration touching every login path, not this endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:46:38 +05:30
Suriya
5864204d32 Zero the produce rates, on the owner's instruction
The eight fresh lines at 1135 held 8, 12 and 18. Fresh unbranded fruit and
chilled fish are nil-rated under Indian GST, so those were overcharging.

Held back on the first pass and reported as REVIEW, because every one is a
reduction of a live tax rate and that is a decision for whoever signs the
returns rather than something a script should quietly do. Put to the owner and
released explicitly.

Two readings are assumed and are worth checking against what the counter
actually sells. Maceral and Tuna are taken as fresh or chilled — frozen,
branded or packaged fish is 5%. Hatsun curd was already 0 and stays there as
plain curd; flavoured yoghurt would be 5%.

The undo SQL for all eight is in the tool's output and restores the previous
rates exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:51:56 +05:30
Suriya
d0c3cb751e Document the sale-date contract, and rate the packaged goods
parsePosSaleDate needed no change — RFC3339Nano already accepts the offset the
terminal now sends, and Format("2006-01-02") on a zoned time still yields the
till's own trading day rather than UTC's. But the ordering of those layouts is
load-bearing and nothing said so, and the two bare layouts are a legacy that
should be recognisable as one: they exist for terminals built before the offset,
whose bills record an instant wrong by the offset with nothing in the payload to
recover it from. Two tests pin both halves, including the case that motivated
this — 00:30 IST, where UTC has not yet rolled into the same day.

The GST script writes only the four packaged lines at 1185, which sat at 0 and
were being billed with no tax at all.

It deliberately does not touch the produce at 1135. That was written believing
every rate was 0 — read from a field name that does not exist in the response,
so the check silently returned nothing. The rows in fact hold 8, 12 and 18, and
under Indian GST fresh unbranded fruit and chilled fish are nil-rated, so
several look like overcharging. Every correction there is a reduction of a live
rate, which belongs to whoever signs the returns rather than to a script. They
are reported as REVIEW and left as found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:18:17 +05:30
Suriya
11595ad415 Record the probes that touched live data
seedprices is the only account of what the 15 seeded retail prices were and how
to put the zeros back — products at 1135 and 1185 were all at 0, so nothing was
sellable and the terminal could not be exercised at all. It refuses to overwrite
a price a human already set, so re-running it is safe.

termfixcleanup removes the one bill posted to prove the terminalid fix against
production. Named by its own terminalorderid rather than by date or by "the
newest row": pos_orders holds real takings and is not a table to run an
unbounded DELETE against.

healthproof carries a warning it did not have when it was written. MQTT_USER in
.env is pos_ingest, which the ACL denies publish on the health topic, and
Mosquitto answers an ACL-denied QoS 1 publish with a PUBACK before discarding
it. So the tool connects, reports success, and nothing arrives — indistinguishable
from a dead consumer, which is how it read for an hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:48:24 +05:30
Suriya
ec672a3087 Add an HTTP heartbeat, and file bills under the till that rang them
Two faults found while checking whether today's live bills had landed. They
had — 17 of them, complete — but both of these were sitting in the same data.

**Health existed only over MQTT.** The consumer subscribes to the health topic
and has done since startup, but a terminal on the HTTP route has no way to
reach it. Today's terminal was on HTTP, so it reported nothing and the board
showed "online 0 of 1" while the till was demonstrably alive and selling.
POST /pos/health now takes the same payload the broker carries, into the same
Redis record, so the board cannot tell the two routes apart and does not need
to. It answers 202 and swallows failures: a till that cannot say how it is must
still sell.

**terminalid was empty on 16 of 17 bills.** The consumer backfills a missing
terminal code from the topic, but onto the batch, while the row was built from
the order — the two never met, and importPosOrder was not handed the batch's
value at all. Over HTTP there was no topic to fall back on either. So the
invoice numbers read INV-2608-T5EDD-000NN while the column they should have
matched was blank, and `byterminal` on the sales summary grouped almost
everything under "". The bill's own terminal now wins with the batch's as the
fallback, trimmed, so whitespace is not mistaken for a code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:48:24 +05:30
36 changed files with 5680 additions and 17 deletions

View File

@@ -1,14 +1,19 @@
package controllers
import (
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"nearle/middleware"
"nearle/models"
"nearle/repositories"
"nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
@@ -95,6 +100,63 @@ func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error {
return c.Status(http.StatusOK).JSON(ack)
}
// IngestHealth records one heartbeat from a till.
//
// The same heartbeat the broker carries, over HTTP, because presence was
// previously reachable *only* over MQTT — a terminal configured for the HTTP
// route reported bills perfectly and never appeared on the fleet board at all,
// with nothing anywhere to say why. A monitoring feature that silently does not
// exist on one of two supported transports is worse than no feature.
//
// Answers 202 rather than 200: nothing is committed, and the till is told not
// to wait on it. Failures are swallowed for the same reason the MQTT path
// swallows them — a terminal that cannot say how it is must still sell, and a
// blank square on a dashboard beats a till that stopped because Redis was busy.
func (ctl *PosController) IngestHealth(c *fiber.Ctx) error {
var health models.PosHealth
if err := c.BodyParser(&health); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "could not read the heartbeat: " + err.Error(),
"status": false,
})
}
// Over MQTT these come from the topic. There is no topic here, so the body
// is the only source and both are required — a heartbeat that cannot say
// which till it belongs to is unfilable.
if strings.TrimSpace(health.Terminalid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "terminal_id is required",
})
}
if strings.TrimSpace(health.Locationid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "location_id is required",
})
}
// Matches the consumer: a bare {"status":"offline"} is a Last Will and must
// survive as-is, but an unset status from a till that is plainly talking to
// us means online.
if strings.TrimSpace(health.Status) == "" {
health.Status = "online"
}
if err := ctl.posService.RecordHealth(c.Context(), health); err != nil {
// Logged, not returned. See above — the till must not slow down for it.
log.Printf("pos: could not record heartbeat from %s/%s over HTTP: %v",
health.Locationid, health.Terminalid, err)
}
return c.Status(http.StatusAccepted).JSON(fiber.Map{
"status": true, "code": http.StatusAccepted,
})
}
// Catalogue answers a terminal's product pull.
func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
storeID := strings.TrimSpace(c.Query("store_id"))
@@ -319,3 +381,452 @@ func posIngestError(c *fiber.Ctx, op string, err error) error {
"status": false,
})
}
// Login signs a terminal in and returns its session.
//
// The one POS route that is deliberately left unauthenticated — it is where a
// token comes from. Everything else on the group sits behind the session this
// issues.
func (ctl *PosController) Login(c *fiber.Ctx) error {
var req models.PosLoginRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "invalid request body",
})
}
if strings.TrimSpace(req.Authname) == "" && strings.TrimSpace(req.Contactno) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "an email or mobile number is required",
})
}
session, err := ctl.posService.Login(req)
if err != nil {
// A rejected credential is 401 and says nothing about which half was
// wrong. Anything else is the deployment's problem, not the caller's,
// and is logged rather than described down the wire.
if repositories.PosLoginRejected(err) {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": err.Error(),
})
}
log.Printf("pos login (%s): %v", req.Authname, err)
return c.Status(http.StatusForbidden).JSON(fiber.Map{
"code": http.StatusForbidden, "status": false, "message": err.Error(),
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": "Login successful",
"details": session,
})
}
// Session echoes back who the caller is, per their token.
//
// What a till calls on start-up to find out whether the session it saved
// yesterday is still good, without having to make a real request and interpret
// the failure. Answers 401 through the middleware when it is not.
func (ctl *PosController) Session(c *fiber.Ctx) error {
claims, ok := middleware.PosClaimsFrom(c)
if !ok {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": "no session token was presented",
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": fiber.Map{
"user_id": claims.Userid,
"tenant_id": claims.Tenantid,
"location_id": claims.Locationid,
"store_id": strconv.Itoa(claims.Locationid),
"role_id": claims.Roleid,
"terminal_id": claims.Terminalid,
"expires_at": time.Unix(claims.Expiresat, 0).UTC().Format(time.RFC3339),
},
})
}
// Staff lists who may ring a bill at this terminal's outlet.
//
// Scoped by the caller's own session rather than by a query parameter. A till
// asking "who works here" must not be able to ask on behalf of another shop,
// and the answer carries PINs — so the outlet comes from the token, and a
// request without one is refused whatever POS_AUTH_REQUIRED says.
func (ctl *PosController) Staff(c *fiber.Ctx) error {
claims, ok := middleware.PosClaimsFrom(c)
if !ok {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": "a session token is required to read staff",
})
}
staff, err := ctl.posService.Staff(claims.Tenantid, claims.Locationid)
if err != nil {
return posServerError(c, "Staff", err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": models.PosStaffResponse{
Locationid: claims.Locationid,
Staff: staff,
},
})
}
// ------------------------------------------------------------- Till staff
//
// A shop runs its own counter. A supervisor creates their cashiers from the
// terminal, and every one of these reads the tenant and outlet from the
// caller's session token rather than from the request — so a supervisor at one
// shop cannot create, edit or list staff at another. That is the same inversion
// that stopped a till naming its own store id, applied to people.
// posManager returns the caller's session, provided they may manage staff.
func posManager(c *fiber.Ctx) (utils.PosClaims, error) {
claims, ok := middleware.PosClaimsFrom(c)
if !ok {
return claims, fiber.NewError(http.StatusUnauthorized,
"a session token is required")
}
if !models.PosRoleCanManageStaff(claims.Roleid) {
// A cashier signing in on the same terminal must not be able to mint
// themselves a supervisor.
return claims, fiber.NewError(http.StatusForbidden,
"only a supervisor can manage till users")
}
return claims, nil
}
// CreatePosUser adds a cashier or supervisor at the caller's outlet.
func (ctl *PosController) CreatePosUser(c *fiber.Ctx) error {
claims, err := posManager(c)
if err != nil {
return posClaimError(c, err)
}
var req models.PosUserRequest
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
user, err := ctl.posService.CreateUser(claims.Tenantid, claims.Locationid, claims.Configid, req)
if err != nil {
// Every failure here is something the caller can act on — a bad role, a
// PIN already in use, a name left blank — so it is reported as a 400
// with the reason rather than logged and hidden behind a 500.
return posBadRequest(c, err)
}
return c.Status(http.StatusCreated).JSON(fiber.Map{
"code": http.StatusCreated, "status": true,
"message": "User created", "details": user,
})
}
// UpdatePosUser edits one of the caller's own till users.
func (ctl *PosController) UpdatePosUser(c *fiber.Ctx) error {
claims, err := posManager(c)
if err != nil {
return posClaimError(c, err)
}
var req models.PosUserRequest
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
user, err := ctl.posService.UpdateUser(claims.Tenantid, claims.Locationid, req)
if err != nil {
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": "User updated", "details": user,
})
}
// ListPosUsers returns the till users at the caller's outlet.
//
// Readable by anyone signed in, not only a supervisor: the terminal needs the
// list to show who is on shift, and a cashier can already see their colleagues
// standing next to them. PINs are the part that matters, and those only go to
// somebody who could set them anyway.
func (ctl *PosController) ListPosUsers(c *fiber.Ctx) error {
claims, ok := middleware.PosClaimsFrom(c)
if !ok {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": "a session token is required",
})
}
users, err := ctl.posService.ListUsers(
claims.Tenantid, claims.Locationid,
strings.EqualFold(c.Query("include_inactive"), "true"),
)
if err != nil {
return posServerError(c, "ListPosUsers", err)
}
// A cashier sees who is on shift, not how to sign in as them.
if !models.PosRoleCanManageStaff(claims.Roleid) {
for i := range users {
users[i].Pin = ""
}
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": fiber.Map{"location_id": claims.Locationid, "users": users},
})
}
// DeletePosUser retires a till user. Deactivates rather than deletes — bills
// carry the cashier's name and shifts settle against it.
func (ctl *PosController) DeletePosUser(c *fiber.Ctx) error {
claims, err := posManager(c)
if err != nil {
return posClaimError(c, err)
}
userID, convErr := strconv.Atoi(strings.TrimSpace(c.Query("user_id")))
if convErr != nil || userID <= 0 {
return posBadRequest(c, fmt.Errorf("user_id is required"))
}
if userID == claims.Userid {
// Otherwise the last supervisor at a shop can lock everybody out with
// one tap, and only we can undo it.
return posBadRequest(c, fmt.Errorf("you cannot deactivate the account you are signed in as"))
}
if err := ctl.posService.DeactivateUser(claims.Tenantid, claims.Locationid, userID); err != nil {
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "User deactivated",
})
}
// PinLogin signs somebody in by PIN at a terminal that is already open.
//
// Requires an existing valid session, and that is the whole security model
// here: four digits is ten thousand guesses, which is no barrier at all to an
// anonymous caller. Tying it to a token means a supervisor has already opened
// the terminal with a real password, and the guesses are confined to one
// outlet's own staff.
//
// The new session is minted fresh rather than derived from the presented one,
// so a cashier taking over from a supervisor drops the supervisor's
// permissions instead of inheriting them.
func (ctl *PosController) PinLogin(c *fiber.Ctx) error {
claims, ok := middleware.PosClaimsFrom(c)
if !ok {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": "sign the terminal in with an email and password before using PIN sign-in",
})
}
var req models.PosLoginRequest
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
if strings.TrimSpace(req.Pin) == "" {
return posBadRequest(c, fmt.Errorf("a PIN is required"))
}
session, err := ctl.posService.LoginWithPin(claims.Tenantid, claims.Locationid, req.Pin)
if err != nil {
if repositories.PosLoginRejected(err) {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": "that PIN was not recognised",
})
}
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": "Signed in", "details": session,
})
}
// posClaimError renders the fiber.Error that posManager returns.
func posClaimError(c *fiber.Ctx, err error) error {
var fe *fiber.Error
if errors.As(err, &fe) {
return c.Status(fe.Code).JSON(fiber.Map{
"code": fe.Code, "status": false, "message": fe.Message,
})
}
return posServerError(c, "posClaims", err)
}
// ------------------------------------------------------- Till staff, from the web
//
// The same staff management as `/pos/users`, for the console an admin actually
// uses. Deliberately the same service calls underneath rather than a parallel
// implementation: a supervisor created from a browser must be the same thing as
// one created at a counter, and two code paths writing one table is exactly how
// that stops being true.
//
// The difference is where the outlet comes from. A terminal proves it with a
// signed token; the console asserts it, because it has no session of its own.
// So it is verified against the tenant before anything is written — which is
// weaker than a signature, and is why these should move behind the same guard
// once the console can hold a session.
// posWebScope reads and checks the tenant and outlet a console request names.
func (ctl *PosController) posWebScope(tenantID, locationID int) error {
if tenantID <= 0 {
return fmt.Errorf("tenantid is required")
}
if locationID <= 0 {
return fmt.Errorf("locationid is required")
}
allowed, err := ctl.posService.LocationAllowed(tenantID, locationID)
if err != nil {
return fmt.Errorf("could not verify the outlet")
}
if !allowed {
// Not "no such outlet" — that would confirm which ids exist. It did not
// belong to the tenant asking, and that is all the caller needs.
return fmt.Errorf("outlet %d does not belong to tenant %d", locationID, tenantID)
}
return nil
}
// WebCreatePosUser adds a supervisor or cashier from the console.
func (ctl *PosController) WebCreatePosUser(c *fiber.Ctx) error {
var req models.PosUserWebRequest
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
return posBadRequest(c, err)
}
// The configid the outlet's other people already use, so a new cashier is
// visible to the same portal as their colleagues. Asked for rather than
// derived would mean a console sending a number nobody can look up.
configID := ctl.posService.ConfigidFor(req.Tenantid)
user, err := ctl.posService.CreateUser(req.Tenantid, req.Locationid, configID, req.PosUserRequest)
if err != nil {
return posBadRequest(c, err)
}
return c.Status(http.StatusCreated).JSON(fiber.Map{
"code": http.StatusCreated, "status": true,
"message": "User created", "details": user,
})
}
// WebUpdatePosUser edits one of an outlet's till users from the console.
func (ctl *PosController) WebUpdatePosUser(c *fiber.Ctx) error {
var req models.PosUserWebRequest
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
return posBadRequest(c, err)
}
user, err := ctl.posService.UpdateUser(req.Tenantid, req.Locationid, req.PosUserRequest)
if err != nil {
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": "User updated", "details": user,
})
}
// WebListPosUsers lists an outlet's till users for the console.
func (ctl *PosController) WebListPosUsers(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
if err := ctl.posWebScope(tenantID, locationID); err != nil {
return posBadRequest(c, err)
}
users, err := ctl.posService.ListUsers(tenantID, locationID,
strings.EqualFold(c.Query("include_inactive"), "true"))
if err != nil {
return posServerError(c, "WebListPosUsers", err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": fiber.Map{"location_id": locationID, "users": users},
})
}
// WebDeletePosUser retires a till user from the console.
func (ctl *PosController) WebDeletePosUser(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
if err := ctl.posWebScope(tenantID, locationID); err != nil {
return posBadRequest(c, err)
}
userID, err := strconv.Atoi(strings.TrimSpace(c.Query("userid")))
if err != nil || userID <= 0 {
return posBadRequest(c, fmt.Errorf("userid is required"))
}
if err := ctl.posService.DeactivateUser(tenantID, locationID, userID); err != nil {
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "User deactivated",
})
}
// WebPosRoles lists the roles a console may offer.
//
// Served rather than hardcoded in the console, because the numbers are this
// backend's business. A console that hardcoded 7 and 8 would be wrong the day
// they change, and would have no way to know.
func (ctl *PosController) WebPosRoles(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": []fiber.Map{
{
"role_id": models.PosRoleSupervisor, "role": "supervisor",
"label": models.PosRoleName(models.PosRoleSupervisor),
"description": "Runs the terminal: imports, settings, voids, and " +
"creating counter staff. Signs in at a till only — a till " +
"account has no Nearle Daily login.",
},
{
"role_id": models.PosRoleCashier, "role": "cashier",
"label": models.PosRoleName(models.PosRoleCashier),
"description": "Billing only. Signs in at a till with their own username " +
"and password, so a shop can open without a supervisor present.",
},
},
})
}

View File

@@ -328,8 +328,12 @@ func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error {
}
if err := ctl.tenantService.CreateStaff(data); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"code": http.StatusConflict,
// A rejected PIN, a missing name, a role nobody set — these are things
// the person filling in the form can fix, so they come back as 400 with
// the reason. This answered 500 with a body claiming 409, which told a
// console nothing it could act on and told the operator less.
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": err.Error(),
"status": false,
})

625
docs/POS_LOGIN.md Normal file
View File

@@ -0,0 +1,625 @@
# Nearle POS — Terminal Sign-In
How a till authenticates, and how it finds out which shop it belongs to.
**Base URL** `https://fiesta.nearle.app/live/api/v1/pos`
**Live since** 6 Aug 2026, `v1.3.98`
---
## What changed, and why it matters
A terminal used to hold a store id typed into Settings and a password compiled
into the app. That made the store id a **claim** rather than a fact: any till
could name any outlet and be believed, so changing one number on one screen
moved a terminal into another tenant's books. The password was identical on
every install of a build.
Now a person signs in with their own back-office account, and the outlet
arrives **as a consequence** — sealed inside a signed token the terminal cannot
edit, and re-checked by the server on every request.
The rule to hold onto: **the till no longer decides which shop it is. It is
told.**
---
## Quickstart
```bash
BASE=https://fiesta.nearle.app/live/api/v1/pos
# 1. Sign in
curl -s -X POST $BASE/login \
-H 'Content-Type: application/json' \
-d '{"authname":"rsselvapuram@gmail.com","password":"…","terminal_id":"T5EDD"}'
# 2. Use the token on everything else
curl -s $BASE/session -H "Authorization: Bearer $TOKEN"
```
---
## The flow
These steps are in order, and the order matters.
**1. Sign in.** `POST /login` with the operator's own credentials — the same
`app_users` account they use for the web console. There is no separate POS
password.
**2. Read `store_id` out of the response.** Do not ask anyone to type it. It is
whatever the back office says that account's outlet is.
**3. If `locations` has more than one entry, ask which one.** Only then. A
single-outlet account gets a list of one and must never see a picker.
**4. Save the token.** Platform keystore, not a plain file or SQLite — it is a
bearer credential for a whole trading day. Restore it on launch **before** any
upload or catalogue pull runs.
**5. Send it on every request** as `Authorization: Bearer <token>`.
**6. Import `staff`.** Replace the till's local staff with what came down, and
deactivate anything that wasn't in the list. That is what retires the built-in
PINs.
---
## `POST /login`
The only unauthenticated route. It is where a token comes from.
### Request
```json
{
"authname": "rsselvapuram@gmail.com",
"password": "…",
"terminal_id": "T5EDD",
"device_id": "a5f3…",
"location_id": 1135,
"configid": 1
}
```
| Field | Required | Notes |
|---|---|---|
| `authname` | yes* | Email. **Or** send `contactno` instead. |
| `contactno` | yes* | Mobile number, as an alternative to `authname`. |
| `password` | yes | |
| `terminal_id` | no | This till's short code, e.g. `T5EDD`. Recorded on the session. |
| `device_id` | no | The device's stable UUID. |
| `location_id` | no | **Only** meaningful for a multi-outlet account. A request, not an assertion — it is checked against what the account may reach. |
| `configid` | no | Inferred when absent. Send it only if you get the ambiguity error below. |
\* one of `authname` or `contactno`.
### Response — `200`
```json
{
"code": 200,
"status": true,
"message": "Login successful",
"details": {
"token": "eyJ1aWQiOjEy….K3p9",
"expires_at": "2026-09-05T10:51:17Z",
"user_id": 1229,
"full_name": "Selvapuram",
"email": "rsselvapuram@gmail.com",
"role_id": 0,
"tenant_id": 1087,
"tenant_name": "Ragul Stores",
"store_id": "1135",
"location_id": 1135,
"location_name": "Ragul stores Selvapuram",
"gstin": "123456",
"address": "…",
"phone": "…",
"locations": [
{ "location_id": 1135, "location_name": "Ragul stores Selvapuram",
"address": "", "city": "", "status": "Active" }
],
"staff": [
{ "user_id": 1148, "full_name": "Ragul Kannan",
"role": "Super admin", "pin": "1111", "status": "Active" }
]
}
}
```
### The fields that matter
**`store_id`** — a string, because that is the shape every uplink already
sends. Use it verbatim as the `store_id` on `/orders`, `/customers` and
`/catalogue`. It is the same value as `location_id`, handed back in the form it
will be replayed in.
**`token`** — **opaque**. Do not parse it, do not read anything out of it, do
not trust anything it appears to say. Its only correct use is to hand it back.
**`expires_at`** — 30 days out. Long on purpose: a shop signs a terminal in once
and expects it to keep working. Forcing a re-login mid-shift means a queue of
customers waiting while somebody finds the manager.
**`gstin` / `address` / `phone`** — print these on the receipt. They are a legal
requirement on a GST invoice and they used to be compile-time constants, so a
shop correcting its GSTIN had to wait for a rebuild. Write them locally on
sign-in.
**`locations`** — every outlet this account may open a till at. Length 1 is the
normal case.
**`staff`** — see [Staff and PINs](#staff-and-pins). **Often empty.**
---
## `GET /session`
Answers who the caller is, per their token. What a till calls on launch to
check whether yesterday's session is still good, without making a real request
and interpreting the failure.
Requires the token. Returns `401` when there isn't one.
```json
{
"code": 200,
"status": true,
"details": {
"user_id": 1229,
"tenant_id": 1087,
"location_id": 1135,
"store_id": "1135",
"role_id": 0,
"terminal_id": "PROBE",
"expires_at": "2026-09-05T10:51:17Z"
}
}
```
---
## `GET /staff`
Who may ring a bill at this terminal's outlet. For pulling down somebody hired
mid-shift without signing the terminal out.
**Takes no parameters.** The answer carries PINs, so the outlet comes from the
caller's own token — a till must not be able to ask who works at the shop next
door. A request without a token is refused whatever the enforcement setting is.
```json
{
"code": 200,
"status": true,
"details": {
"location_id": 1135,
"staff": []
}
}
```
---
## Roles
Two POS roles, added to `app_roles`:
| roleid | Role | Can |
|---|---|---|
| `7` | **Supervisor** | everything a till does, **plus** creating and editing counter staff |
| `8` | **Cashier** | billing only |
The session carries both, so the terminal never has to map role ids itself:
```json
{ "role_id": 7, "role": "Supervisor", "can_manage_staff": true }
```
Branch on `can_manage_staff`, not on the number. `app_roles` holds six rows for
four back-office roles (Admin is both 3 and 5, Manager both 4 and 6) and most
accounts carry an id that is not in the table at all — any mapping written on
the terminal would be wrong.
### The till and Nearle Daily do not share accounts
`app_users` is the only thing the two products have in common. An account
belongs to one or the other, never to both:
| | Nearle Daily app + console | POS terminal |
|---|---|---|
| roles | `1``6` — Super admin, Operations, Admin, Manager | `7` Supervisor, `8` Cashier |
| `/applogin`, `/tenant/weblogin`, `/tenant/login` | yes | **not found** |
| `POST /v1/pos/login` | **403** | yes |
| listed by `/getallusers`, `/getstaffs` | yes | **hidden** |
A Nearle Daily **Super admin is not the administrator of anybody's POS.** The
back office reaches a till by *provisioning* a Supervisor from the console; it
never becomes one by signing in.
This was the other way round until it was measured. Roles 16 counted as
supervisors, on the reasoning that somebody who already administers a shop from
a browser is not made less privileged by standing at the counter. That handed
till-supervisor powers to **68 live accounts, 59 of them platform Super
admins**, while the actual shop accounts carry `roleid 0` and were refused.
Both directions are now closed in the queries themselves rather than in a check
each call site has to remember — a till account is not *rejected* by the app
login, it is simply not found.
**`role_id` 0 is not a role.** It is what an account carries when nobody set
one, 22 live accounts have it including a delivery rider, and it grants nothing
on either side.
### Every till account gets its own username and password
Both roles. A PIN cannot open a *closed* terminal — `/pos/login/pin` requires a
session that already exists — so a PIN-only account works only while somebody
else is standing there to unlock the till first. For a Supervisor that was an
outright deadlock; for a Cashier it meant a shop that could not open until two
people had arrived, and whoever gets in at seven is as often the cashier as the
supervisor.
So a Cashier signs in exactly like a Supervisor does, and the *role* decides
what they get — not which credential they used:
```
POST /v1/pos/login supervisor.1185@pos.nearle.in -> full shell
POST /v1/pos/login cashier.1185@pos.nearle.in -> billing only
```
`POST /pos/users` generates both when the request omits them, and returns the
password **once**, in the creation response only:
```json
{ "user_id": 1452, "role": "Cashier",
"authname": "cashier.1185@pos.nearle.in",
"password": "9tWx2KUJksM5Rm", "pin": "4513", "has_password": true }
```
`GET /pos/users` never returns a password, only `has_password`. An admin who
loses it reissues rather than looks it up.
Send `authname` and `password` explicitly if the shop wants its people signing
in as themselves. A generated name that collides — a second cashier at one
outlet — becomes `cashier2.1185@pos.nearle.in`; a name **you** supplied is never
adjusted, it is refused, because silently signing somebody in as another
person's address is worse than an error.
The PIN stays optional. It switches operator at an open counter, which not every
shop does, and it is the one credential the till holds in plaintext to hand
around — so it is set deliberately, never by default.
---
## `POST /pos/login/pin` — signing on at an open terminal
For a cashier taking over a counter a supervisor has already opened.
**Requires an existing valid token.** That is the security model, not an
oversight: four digits is ten thousand guesses, which is no barrier at all to an
anonymous caller. Tying it to a session means a supervisor has opened the
terminal with a real password first, and the guesses are confined to that one
outlet's staff.
```bash
curl -s -X POST $BASE/login/pin \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"pin":"1602"}'
```
Returns a **new** session, with the same shape as `/login`. New rather than
reused, because the token carries the role — a cashier taking over from a
supervisor must drop their permissions, not inherit them.
`401` if the PIN is not recognised. `400` if two people at the outlet share it,
which creation refuses but older data may contain.
---
## `/pos/users` — the shop's own counter staff
A supervisor creates their own cashiers, from the terminal.
**The outlet is never in the request.** Tenant and location come from the
caller's token, so a supervisor at Selvapuram cannot create staff at R mart by
sending a different number — the same inversion that stopped a till naming its
own store id.
### `POST /pos/users`
```json
{
"full_name": "Asha Kumar",
"role": "cashier",
"pin": "4821",
"authname": "asha@shop.test",
"password": "…"
}
```
| Field | Notes |
|---|---|
| `full_name` | required; split across `firstname`/`lastname` |
| `role` | `"supervisor"` or `"cashier"`. Anything else is refused — never defaulted |
| `pin` | optional, 4 digits. See the rules below |
| `authname` | optional. **Generated if omitted**`cashier.1185@pos.nearle.in`, or `cashier2.…` if that is taken |
| `password` | optional. **Generated if omitted**, and returned once in this response |
**Everyone gets a username and a password, cashiers included**, because a PIN
cannot open a closed terminal. Omit both fields and they are generated for you,
so provisioning a shop is one call per person.
The response is the only time the password is returned; `GET /pos/users` reports
`has_password` and nothing more.
:warning: **PIN rules, and why**
- **Exactly 4 digits, and cannot start with `0`.** `app_users.pin` is a
`bigint`, so `"0451"` would be stored as `451` and read back as three digits —
a cashier would type four and be refused for ever. One such account already
exists in live data.
- **`1234`, `1111`, `2345`, `4321`, `9999`, `2222`, `3456`, `0000` are refused.**
Live data has `1234` on eleven accounts and `1111` on nine.
- **Unique within the outlet**, not globally. A PIN only distinguishes people at
one counter; making it platform-unique would exhaust the space fast.
Answers `201` with the created user. Every failure is a `400` carrying the
reason, because all of them are things the caller can fix.
### `GET /pos/users`
Readable by anyone signed in — the terminal needs it to show who is on shift.
**A cashier gets the list with `pin` blanked**; only somebody who could set a
PIN gets to see one. `?include_inactive=true` to see leavers.
### `PUT /pos/users`
Same fields plus `user_id`. Send only what changes. Supervisor only.
### `DELETE /pos/users?user_id=9189`
Deactivates — never deletes, because bills carry the cashier's name and shifts
settle against it. Supervisor only, and you cannot deactivate the account you
are signed in as: otherwise the last supervisor at a shop can lock everyone out
with one tap.
---
## Creating staff from the web console
The same staff management, for the screen an admin actually uses. Registered
under both `/v1/web/tenants` and `/v1/mob/tenants`.
```
GET /v1/web/tenants/posroles
GET /v1/web/tenants/getposusers?tenantid=1087&locationid=1135
POST /v1/web/tenants/createposuser
PUT /v1/web/tenants/updateposuser
DELETE /v1/web/tenants/deleteposuser?tenantid=1087&locationid=1135&userid=9189
```
`createposuser` takes the same body as `/pos/users`, plus the outlet — the
console has no session token, so it has to name one:
```json
{
"tenantid": 1087,
"locationid": 1135,
"full_name": "Asha Kumar",
"role": "cashier",
"pin": "4821"
}
```
**These run the same service calls as `/pos/users`.** A supervisor created from
a browser is the same row, with the same rules applied, as one created at a
counter — same PIN validation, same duplicate check, same identity-column
allocation. That is the point of them: two paths writing one table is how the
two stop matching.
`configid` is never asked for. It is inferred from whichever value the tenant's
existing accounts carry — a number nobody looks up, that varies per tenant (1087
is spread across 1, 6 and 15), and that silently creates an account nobody can
find if it is wrong.
`GET /posroles` returns the two roles with their ids and labels, so a console
offering the choice never has to know that supervisor is `7`.
### :red_circle: These are unauthenticated
Like every other route in the `/v1/web` and `/v1/mob` groups — there is no auth
middleware anywhere on the web API. The outlet is checked against the tenant
before anything is written, so a caller cannot create staff at a shop that is
not theirs *given a tenant id* — but nothing proves the caller is that tenant.
So this mints till credentials on an unauthenticated request. It is consistent
with the rest of the platform, and it is still the weakest point in this design.
They should move behind a session guard as soon as the console can hold one.
The terminal routes are not affected: `/pos/users` proves its outlet with a
signed token.
---
## Using the token
```
Authorization: Bearer eyJ1aWQiOjEy….K3p9
```
`X-Pos-Token: <token>` is accepted as a fallback, because some shop routers
strip `Authorization` headers over plain HTTP. A bare token with no `Bearer `
prefix is tolerated too.
Send it on **every** POS call: `/orders`, `/customers`, `/catalogue`, `/health`,
`/sales*`, `/session`, `/staff`.
### What the server checks
1. The token verifies against our signing key and has not expired.
2. The outlet named in the request belongs to the token's tenant.
The second is the one that matters. A valid token is a licence to name **your**
outlets, not any outlet. The outlet is read from the query string *and* from the
JSON body, because `/orders` and `/customers` carry `store_id` in the batch and
never in the URL.
```
GET /catalogue?store_id=1135 → 200 your outlet
GET /catalogue?store_id=1185 → 403 {"message":"this session cannot reach outlet 1185"}
```
---
## Errors
### Sign-in
| Code | Meaning | What the till should do |
|---|---|---|
| `400` | Body unreadable, or neither `authname` nor `contactno` sent | Fix the request |
| `401` | `those sign-in details were not recognised` | Ask them to re-type. **Wrong email and wrong password give the same message** — deliberately, so the endpoint isn't a directory of who banks here |
| `403` | Real account, but it can't open this till | Show the message; re-typing won't help |
The `403` messages, verbatim:
- `this account is not set up for the till; ask your store admin to add you as a Supervisor or Cashier in the web console`
- `this account is inactive; contact your administrator`
- `this account has no password set; set one in the web console first`
- `this account is not attached to a tenant and cannot open a till`
- `no active outlet is registered for this account`
- `this account cannot open a till at outlet 1185`
- `more than one account uses these sign-in details; ask your administrator for the configid and send it with the login`
That last one is real, not theoretical: `authname` is not unique in this schema.
Live data has the same address twice. We refuse rather than pick one, because
picking wrong means billing into another tenant's books.
The **first** one is the common case now, and it is deliberately specific where a
bad password is deliberately vague. By the time it fires the caller has already
proved the credential, so naming the reason leaks nothing they did not just
demonstrate — and the vague answer would send a shop owner hunting for a
password that was never wrong.
### Authenticated routes
| Code | Meaning |
|---|---|
| `401` | No token, malformed token, bad signature, or expired — sign in again |
| `403` | Valid token naming an outlet the tenant doesn't own |
---
## Multi-outlet accounts
An account pinned to one location gets that location. An account with no
location — a proprietor with several shops — gets all of the tenant's active
outlets.
```
rsselvapuram@gmail.com → 1 outlet (1135, Selvapuram)
raguladmin@gmail.com → 6 outlets (1097, 1135, 1137, 1138, 1139, 885536644)
```
When `locations.length > 1`:
1. Show a picker. **Don't make it dismissable** — a terminal has to be standing
somewhere, and silently defaulting to the first outlet is how a day's takings
get filed against the wrong shop.
2. Sign in **again** with `location_id` set to their choice.
Re-signing-in is not laziness. The outlet is inside the signed token, so only
the server can issue one for a different shop — and re-checking entitlement at
that moment is the point.
---
## Staff and PINs
Two different credentials, easily confused:
| | Says | Checked by |
|---|---|---|
| **Sign-in** (email + password) | which **shop** this terminal is | the server |
| **PIN** | which **person** rang this bill | the terminal, offline |
The PIN stamps `cashiername` and is what shifts settle against. It is **shift
attribution, not a security boundary** — the boundary is the token.
### The PIN comes down in the clear
Over TLS, and that's considered rather than sloppy. Four digits are
brute-forceable in microseconds whatever they're wrapped in, so hashing
server-side would buy the appearance of strength and not the substance — while
costing something real, because the terminal salts every PIN with its own random
salt before storing it and could never verify a hash computed on the server.
**Store it hashed on the device.** It arrives in the clear; it must not sit that
way.
### Importing
Write everyone in `staff`, keyed on `user_id` so a re-sync updates rather than
duplicates. Then **deactivate everything you didn't just import** — that is what
kills the built-in PINs. Deactivate, never delete: bills carry the cashier's
name.
### :warning: `staff` is usually empty today
Only 116 of 596 accounts on the platform have a PIN set. Outlet 1135 — the one
the terminal ships pointed at — has **zero**.
So:
- **An empty list is not a failure.** Do nothing and leave the till exactly as
it was.
- **A list where every PIN is unusable** (`0`, blank) must behave the same way.
Deactivating the local accounts because the back office isn't filled in yet
would leave a counter nobody can sign in to.
The terminal still ships with three seeded logins for exactly this reason. They
retire automatically the moment real staff exist. Filling in real PINs in the
back office is what makes that happen.
---
## Current state
| | |
|---|---|
| Endpoints | live on `v1.3.98`, all three pods |
| Signing key | set in `app-secrets` |
| **Enforcement** | **OFF**`POS_AUTH_REQUIRED` is unset |
Enforcement being off means a request carrying **no** token is still allowed
through, so terminals already trading don't stop the day this ships. It does
**not** mean tokens are ignored:
- a token that's present and invalid is **always** refused;
- a valid token naming another tenant's outlet is **always** refused.
Once the fleet is on a build that signs in, `POS_AUTH_REQUIRED=true` closes the
door on untokened requests.
---
## Known limitations
- **Passwords are stored in plaintext** across the whole platform, not just
here. Fixing it is a migration touching every login path.
- **No role check.** Any active account with a tenant, a password and an active
outlet can open a till — including `roleid 0`, which isn't in `app_roles` at
all and currently includes a delivery rider. The damage is bounded by the
token: they can only reach their own tenant's books.
- **`1135` means two different things.** It's a *location* (Ragul stores
Selvapuram, under tenant 1087) and separately a *tenant* (Suriya Store). Same
number, different tables. Watch for it in logs.

View File

@@ -68,6 +68,45 @@ func (f *fakePosService) SalesSummary(models.PosSalesFilter) (*models.PosSalesSu
return nil, nil
}
// Sign-in plays no part over the broker: a terminal on MQTT authenticates to
// the broker itself, and the topic it publishes on already names its store.
// These exist to satisfy the interface, and returning "denied" is the safer
// stub — a fake that waved authorisation through could hide a real regression.
func (f *fakePosService) Login(models.PosLoginRequest) (*models.PosSession, error) {
return nil, nil
}
func (f *fakePosService) LocationAllowed(int, int) (bool, error) {
return false, nil
}
func (f *fakePosService) Staff(int, int) ([]models.PosStaffMember, error) {
return nil, nil
}
// Staff management plays no part over the broker — a terminal on MQTT publishes
// bills and nothing else. Denied rather than permitted, so a fake cannot hide a
// regression by waving authorisation through.
func (f *fakePosService) CreateUser(int, int, int, models.PosUserRequest) (*models.PosUser, error) {
return nil, nil
}
func (f *fakePosService) UpdateUser(int, int, models.PosUserRequest) (*models.PosUser, error) {
return nil, nil
}
func (f *fakePosService) ListUsers(int, int, bool) ([]models.PosUser, error) {
return nil, nil
}
func (f *fakePosService) DeactivateUser(int, int, int) error { return nil }
func (f *fakePosService) LoginWithPin(int, int, string) (*models.PosSession, error) {
return nil, nil
}
func (f *fakePosService) ConfigidFor(int) int { return 0 }
func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) {
return nil, nil
}

219
middleware/posauth.go Normal file
View File

@@ -0,0 +1,219 @@
package middleware
import (
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
"time"
"nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
// Authorisation for the POS terminal.
//
// Before this, the whole POS surface was open. A till named its own outlet on
// the wire and was believed, so `store_id=1185` in a URL was enough to read
// another tenant's catalogue or post bills into their books. There was no
// middleware in the codebase at all and the `JWT_SECRET_KEY` in the config was
// read and never used.
//
// The fix is two checks, in this order:
//
// 1. the caller holds a token this server signed, and
// 2. the outlet they are naming belongs to the tenant inside that token.
//
// The second is the one that matters. A valid token is not a licence to name
// any location — it is a licence to name *your* locations, and without the
// cross-check a real terminal at one shop could still read the shop next door.
// PosLocalsKey names where the verified claims are parked for handlers.
const PosLocalsKey = "posclaims"
// posAuthRequired reports whether a request without a valid token is refused.
//
// Defaults to OFF, and that is a deliberate, temporary choice rather than an
// oversight. Terminals are already in shops billing real customers against the
// unauthenticated endpoints; switching enforcement on at deploy would stop
// every one of them mid-trade. So the endpoint ships first, tills adopt it, and
// `POS_AUTH_REQUIRED=true` closes the door once the fleet is carrying tokens.
//
// While it is off a token is still *verified* when one is sent, and a request
// carrying a token for the wrong tenant is still refused — the flag only
// decides what happens to a request carrying none.
func posAuthRequired() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv("POS_AUTH_REQUIRED")), "true")
}
// PosAuth verifies the session token and pins the request to its outlet.
func PosAuth(pos services.PosService) fiber.Handler {
return func(c *fiber.Ctx) error {
token := bearerToken(c)
if token == "" {
if posAuthRequired() {
return posUnauthorized(c, "a session token is required; sign in at /pos/login")
}
// Legacy till. Allowed through un-pinned, which is exactly the state
// this middleware exists to end — see posAuthRequired.
return c.Next()
}
claims, err := utils.ParsePosToken(token, time.Now())
if err != nil {
// Always refused, flag or no flag. A token that does not verify is
// a stronger signal than no token at all: nothing sends a broken
// one by accident.
return posUnauthorized(c, err.Error())
}
// The outlet named in the request, if it named one. Every POS route
// spells this differently — `store_id` on catalogue, `locationid` on
// sales, `location_id` on health — so all three are read rather than
// the caller being asked to change.
requested := requestedLocation(c)
if requested > 0 && requested != claims.Locationid {
// A different outlet than the token was issued for. Permitted only
// if the tenant genuinely owns it — a proprietor with six shops
// should be able to look at all six from one signed-in session.
allowed, err := pos.LocationAllowed(claims.Tenantid, requested)
if err != nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
"code": http.StatusServiceUnavailable, "status": false,
"message": "could not verify outlet access",
})
}
if !allowed {
return c.Status(http.StatusForbidden).JSON(fiber.Map{
"code": http.StatusForbidden, "status": false,
"message": "this session cannot reach outlet " + strconv.Itoa(requested),
})
}
}
c.Locals(PosLocalsKey, claims)
return c.Next()
}
}
// bearerToken reads the session out of the request.
//
// `Authorization: Bearer …` is the form to use. `X-Pos-Token` is accepted as
// well because some of the shop routers between a till and this server strip
// Authorization headers on plain HTTP, and a terminal that cannot authenticate
// is a shop that cannot trade.
func bearerToken(c *fiber.Ctx) string {
header := strings.TrimSpace(c.Get("Authorization"))
if header != "" {
if after, found := strings.CutPrefix(header, "Bearer "); found {
return strings.TrimSpace(after)
}
if !strings.Contains(header, " ") {
// Tolerates a bare token. Terminals in the field get this wrong and
// the alternative is a shop that cannot sell.
return header
}
}
return strings.TrimSpace(c.Get("X-Pos-Token"))
}
// requestedLocation reads whichever outlet parameter this route happens to use.
//
// The three spellings are a wart — `store_id`, `locationid` and `location_id`
// all mean the same thing across the POS routes. Normalising them is a breaking
// change for terminals already in the field, so this reads all three instead
// and leaves the naming alone.
//
// The body is searched as well as the query, and that is not an optional extra:
// the two routes that *write* — order and customer ingest — carry `store_id` in
// a JSON batch and never in the URL. Checking only the query string would leave
// the exact call that posts bills into another tenant's books unguarded, which
// is the hole this middleware exists to close.
func requestedLocation(c *fiber.Ctx) int {
for _, key := range []string{"store_id", "locationid", "location_id"} {
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
return id
}
}
}
return bodyLocation(c)
}
// bodyLocation pulls the outlet out of a JSON request body.
//
// Decoded into a loose map rather than the batch type on purpose. This runs
// before the handler and must not reject anything the handler would have
// accepted — a body that fails to parse here is left to the handler to refuse
// with its own message, and a batch shape that changes later must not silently
// stop being authorised.
//
// `c.Body()` returns the buffered bytes, so reading it here does not consume
// the stream the handler goes on to parse.
func bodyLocation(c *fiber.Ctx) int {
body := c.Body()
if len(body) == 0 || len(body) > 8<<20 {
return 0
}
var probe struct {
Storeid json.RawMessage `json:"store_id"`
Locationid json.RawMessage `json:"location_id"`
}
if err := json.Unmarshal(body, &probe); err != nil {
return 0
}
for _, raw := range []json.RawMessage{probe.Storeid, probe.Locationid} {
if id := asLocationID(raw); id > 0 {
return id
}
}
return 0
}
// asLocationID reads an id that may have been sent as a number or as a string.
//
// The till sends `"store_id": "1135"` and the health payload sends
// `"location_id": "1135"`, both quoted, while other callers send it bare.
// Accepting only one shape would silently skip the check for the other — and a
// skipped check here reads exactly like a passed one.
func asLocationID(raw json.RawMessage) int {
if len(raw) == 0 {
return 0
}
var asString string
if err := json.Unmarshal(raw, &asString); err == nil {
if id, err := strconv.Atoi(strings.TrimSpace(asString)); err == nil && id > 0 {
return id
}
return 0
}
var asNumber int
if err := json.Unmarshal(raw, &asNumber); err == nil && asNumber > 0 {
return asNumber
}
return 0
}
func posUnauthorized(c *fiber.Ctx, message string) error {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false, "message": message,
})
}
// PosClaimsFrom returns the verified session on a request, if it carried one.
//
// The second return distinguishes "no token" from "a token claiming tenant 0",
// which a caller acting on the tenant id must not confuse.
func PosClaimsFrom(c *fiber.Ctx) (utils.PosClaims, bool) {
claims, ok := c.Locals(PosLocalsKey).(utils.PosClaims)
return claims, ok
}

151
middleware/posauth_test.go Normal file
View File

@@ -0,0 +1,151 @@
package middleware
import (
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
)
// The outlet a request names has to be found wherever the route happens to put
// it. These cover the extraction alone — it is the part that decides whether
// the authorisation check runs at all, and a miss here reads exactly like a
// pass.
func locationFor(t *testing.T, method, target, body string) int {
t.Helper()
app := fiber.New()
found := -1
app.All("/probe", func(c *fiber.Ctx) error {
found = requestedLocation(c)
return c.SendStatus(fiber.StatusOK)
})
var reader *strings.Reader
if body == "" {
reader = strings.NewReader("")
} else {
reader = strings.NewReader(body)
}
req := httptest.NewRequest(method, target, reader)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if _, err := app.Test(req); err != nil {
t.Fatalf("probing: %v", err)
}
return found
}
func TestTheOutletIsFoundUnderEveryNameTheRoutesUse(t *testing.T) {
// Three spellings for one thing across the POS routes. Missing any of them
// leaves that route unguarded.
cases := []struct {
name string
target string
}{
{"catalogue says store_id", "/probe?store_id=1135"},
{"sales say locationid", "/probe?locationid=1135"},
{"health says location_id", "/probe?location_id=1135"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := locationFor(t, "GET", tc.target, ""); got != 1135 {
t.Fatalf("wanted outlet 1135, got %d", got)
}
})
}
}
// The two routes that *write* carry the outlet in a JSON batch and never in the
// URL. Checking only the query string would leave the exact call that posts
// bills into another tenant's books unguarded.
func TestTheOutletIsFoundInAnIngestBody(t *testing.T) {
body := `{"batch_id":"b1","terminal_id":"T5EDD","store_id":"1135","orders":[]}`
if got := locationFor(t, "POST", "/probe", body); got != 1135 {
t.Fatalf("wanted outlet 1135 from the batch body, got %d", got)
}
}
// The till quotes its ids; other callers send them bare. Accepting only one
// shape silently skips the check for the other.
func TestAnOutletIsReadWhetherQuotedOrNot(t *testing.T) {
quoted := `{"store_id":"1135"}`
bare := `{"store_id":1135}`
if got := locationFor(t, "POST", "/probe", quoted); got != 1135 {
t.Fatalf("quoted store_id: wanted 1135, got %d", got)
}
if got := locationFor(t, "POST", "/probe", bare); got != 1135 {
t.Fatalf("bare store_id: wanted 1135, got %d", got)
}
}
func TestAHealthBodyNamesItsOutlet(t *testing.T) {
body := `{"terminal_id":"T5EDD","location_id":"1135","status":"online"}`
if got := locationFor(t, "POST", "/probe", body); got != 1135 {
t.Fatalf("wanted outlet 1135 from the health body, got %d", got)
}
}
// A request naming no outlet is not an error — /session names none — so it must
// come back as "nothing to check" rather than as outlet zero.
func TestARequestNamingNoOutletReportsNone(t *testing.T) {
if got := locationFor(t, "GET", "/probe", ""); got != 0 {
t.Fatalf("wanted 0 for a request naming no outlet, got %d", got)
}
if got := locationFor(t, "POST", "/probe", `{"batch_id":"b1"}`); got != 0 {
t.Fatalf("wanted 0 for a body naming no outlet, got %d", got)
}
}
// A body this middleware cannot parse must not be treated as naming an outlet.
// The handler will refuse it on its own terms; guessing here would either
// reject a good request or wave a bad one through.
func TestAnUnparseableBodyNamesNoOutlet(t *testing.T) {
if got := locationFor(t, "POST", "/probe", `{not json at all`); got != 0 {
t.Fatalf("wanted 0 for an unparseable body, got %d", got)
}
}
func TestABearerTokenIsReadInEveryFormTheFieldSends(t *testing.T) {
app := fiber.New()
var got string
app.Get("/probe", func(c *fiber.Ctx) error {
got = bearerToken(c)
return c.SendStatus(fiber.StatusOK)
})
cases := []struct {
name string
header string
value string
want string
}{
{"the standard form", "Authorization", "Bearer abc.def", "abc.def"},
{"a bare token, which terminals send", "Authorization", "abc.def", "abc.def"},
{"the fallback header", "X-Pos-Token", "abc.def", "abc.def"},
{"a scheme we do not issue", "Authorization", "Basic dXNlcjpwdw==", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got = ""
req := httptest.NewRequest("GET", "/probe", nil)
req.Header.Set(tc.header, tc.value)
if _, err := app.Test(req); err != nil {
t.Fatalf("probing: %v", err)
}
if got != tc.want {
t.Fatalf("wanted %q, got %q", tc.want, got)
}
})
}
}

View File

@@ -1,5 +1,7 @@
package models
import "strings"
// Wire format for the Nearle POS terminal.
//
// These types mirror what the till actually publishes, field for field. The
@@ -210,3 +212,269 @@ type PosCatalogueResponse struct {
Customers []PosCatalogueCustomer `json:"customers"`
Retiredids []string `json:"retired_product_ids"`
}
// ---------------------------------------------------------------- Sign-in
//
// A terminal used to hold a store id typed into Settings and a password
// compiled into the app. That made the store id a *claim* rather than a fact:
// any till could name any outlet and be believed, and one leaked build opened
// every tenant on the platform.
//
// These types replace it with the account model the web console already uses.
// A person signs in with their own `app_users` credentials, and the outlet
// comes out of their record instead of going in from the wire.
// PosLoginRequest is what a till sends to sign in.
//
// Authname or Contactno, matching the web console's own login — a shop should
// not need a second set of credentials just because the screen is a till.
//
// Locationid is optional and only means anything for a user entitled to more
// than one outlet: it says which of theirs this terminal is standing in. It is
// checked against what they may reach, never trusted on its own.
type PosLoginRequest struct {
Authname string `json:"authname"`
Contactno string `json:"contactno"`
Password string `json:"password"`
Configid int `json:"configid"`
Locationid int `json:"location_id"`
// A PIN, for signing on at a terminal a supervisor has already opened. Only
// honoured by the PIN route, which requires an existing session — four
// digits is no barrier to an anonymous caller.
Pin string `json:"pin"`
// Which physical till is asking. Recorded on the session so a stolen token
// can be told apart from the terminal it was issued to.
Terminalid string `json:"terminal_id"`
Deviceid string `json:"device_id"`
}
// PosLoginLocation is one outlet a signed-in user may bill for.
type PosLoginLocation struct {
Locationid int `json:"location_id"`
Locationname string `json:"location_name"`
Address string `json:"address,omitempty"`
City string `json:"city,omitempty"`
Status string `json:"status,omitempty"`
}
// PosSession is what a till holds for the rest of the trading day.
//
// Storeid is returned as a string because that is the shape the terminal's
// configuration already stores and sends — handing it back in the form it will
// be replayed in removes a conversion, and a conversion is where a store id
// gets mangled.
type PosSession struct {
Token string `json:"token"`
Expiresat string `json:"expires_at"`
Userid int `json:"user_id"`
Fullname string `json:"full_name"`
Email string `json:"email,omitempty"`
Roleid int `json:"role_id"`
// What the role is called, and the one thing the terminal actually branches
// on. Sent as a flag rather than leaving the till to map role ids itself:
// `app_roles` has six rows for four roles and most accounts carry an id
// absent from it, so any mapping written on the terminal would be wrong.
Role string `json:"role"`
Canmanagestaff bool `json:"can_manage_staff"`
// Which portal this account belongs to. Carried so a supervisor creating a
// cashier gives them the same configid — an account created under the wrong
// one cannot sign into the web console and is invisible to half the
// platform's queries. Not sent to the terminal: it has no use for it and it
// is one more number to get wrong.
Configid int `json:"-"`
Tenantid int `json:"tenant_id"`
Tenantname string `json:"tenant_name"`
Storeid string `json:"store_id"`
Locationid int `json:"location_id"`
Locationname string `json:"location_name"`
Gstin string `json:"gstin,omitempty"`
Address string `json:"address,omitempty"`
Phone string `json:"phone,omitempty"`
// Every outlet this account may sign a terminal into. A single-outlet user
// gets a list of one, so the till has no special case: it shows a picker
// when there is a choice and skips it when there is not.
Locations []PosLoginLocation `json:"locations"`
// The people who may ring a bill at the chosen outlet.
//
// Sent with the session so a terminal is ready to trade the moment it signs
// in, rather than needing a second call before the first customer. May be
// empty — most tenants have no staff recorded yet — and the terminal has to
// cope with that rather than treat it as a failure.
Staff []PosStaffMember `json:"staff"`
}
// PosStaffMember is one person who may ring a bill at an outlet.
//
// Distinct from the account that signs the *terminal* in. The sign-in says
// which shop this till belongs to; this says who is standing at it, and it is
// what gets stamped on a bill as `cashiername` and settled against at the end
// of a shift.
//
// The PIN travels in the clear, over TLS, and that is a considered choice
// rather than an oversight. A four-digit PIN is brute-forceable in microseconds
// whatever it is wrapped in, so hashing it here would buy the appearance of
// strength and not the substance. What it would cost is real: the terminal
// salts every PIN with its own random salt before storing it, so a hash
// computed here could never be verified there without inventing a shared
// scheme and keeping two codebases agreeing about it for ever.
//
// The honest framing is that a PIN is *shift attribution*, not a security
// boundary. The boundary is the session token — which is what stops a till
// reaching another tenant's books at all. The PIN decides which of the people
// already inside a shop gets credited with a sale, and the terminal still
// stores it hashed at rest.
type PosStaffMember struct {
Userid int `json:"user_id"`
Fullname string `json:"full_name"`
Role string `json:"role"`
Pin string `json:"pin,omitempty"`
Status string `json:"status,omitempty"`
}
// PosStaffResponse answers a request for an outlet's people.
type PosStaffResponse struct {
Locationid int `json:"location_id"`
Staff []PosStaffMember `json:"staff"`
}
// ------------------------------------------------------------ POS staff roles
//
// `app_roles` is keyed by roleid and carries a configid, so the same name
// appears more than once — Admin is both 3 and 5, Manager both 4 and 6, one per
// portal. These two are deliberately not per-portal: a till is a till whichever
// tenant owns it, and a role that had to be duplicated per config would be one
// more thing to remember when a tenant is onboarded.
//
// The ids are fixed rather than allocated, because they are referenced from the
// terminal and from this source. `app_roles.roleid` has no sequence and no
// default — every id in that table was assigned by hand — so nothing is being
// worked around here.
const (
// PosRoleSupervisor runs the terminal: settings, imports, price overrides,
// voids, and creating the people below.
PosRoleSupervisor = 7
// PosRoleCashier bills, and nothing else.
PosRoleCashier = 8
)
// PosRoleName maps a role id to what a person calls it.
func PosRoleName(roleID int) string {
switch roleID {
case PosRoleSupervisor:
return "Supervisor"
case PosRoleCashier:
return "Cashier"
}
return ""
}
// PosRoleFromName reads the role off a request.
//
// Accepts the name rather than the number, so a caller never has to hardcode 7
// or 8 — and returns 0 for anything unrecognised, which every caller treats as
// a refusal rather than as a default.
func PosRoleFromName(name string) int {
switch strings.ToLower(strings.TrimSpace(name)) {
case "supervisor":
return PosRoleSupervisor
case "cashier":
return PosRoleCashier
}
return 0
}
// PosRoleEligible reports whether a role may open a till at all.
//
// The terminal and the Nearle Daily application share one `app_users` table,
// and that is the only thing they share. An account belongs to one product or
// the other and never to both: a person who administers a shop from a browser
// does not thereby get a cash drawer, and a cashier does not thereby get the
// back office.
//
// Eligibility is therefore granted explicitly — by provisioning a Supervisor or
// a Cashier from the console — and is never inherited from a back-office role.
// Anything else is refused at sign-in, including roleid 0, which is not a role
// but the absence of one.
func PosRoleEligible(roleID int) bool {
return roleID == PosRoleSupervisor || roleID == PosRoleCashier
}
// PosRoleCanManageStaff reports whether a role may create and edit till users.
//
// Supervisors, and nobody else.
//
// This used to include the back office's own roles 1 to 6, on the reasoning
// that somebody who can already administer a shop from a browser is not made
// less privileged by standing at the counter. That was wrong, and live data
// showed how wrong: it handed till-supervisor powers to 68 accounts, 59 of them
// Nearle Daily Super admins, not one of whom is the administrator of anybody's
// POS. The actual shop accounts carry roleid 0 and were refused.
//
// The back office reaches the till by *provisioning* a supervisor from the
// console, not by becoming one at the counter.
func PosRoleCanManageStaff(roleID int) bool {
return roleID == PosRoleSupervisor
}
// PosUser is a person who signs in at a till.
type PosUser struct {
Userid int `json:"user_id"`
Fullname string `json:"full_name"`
Firstname string `json:"first_name,omitempty"`
Lastname string `json:"last_name,omitempty"`
Authname string `json:"authname,omitempty"`
Contactno string `json:"contactno,omitempty"`
Roleid int `json:"role_id"`
Role string `json:"role"`
Pin string `json:"pin,omitempty"`
Haspassword bool `json:"has_password"`
// The password, returned only in the answer to a creation or a reset and
// never by a listing. An admin who loses it reissues rather than looks it
// up — the right shape even while the column behind it is plaintext.
Password string `json:"password,omitempty"`
Locationid int `json:"location_id"`
Status string `json:"status"`
}
// PosUserRequest creates or edits a till user.
//
// Note what is absent: tenant and location. Both come from the caller's own
// session token. A supervisor creating staff can only ever create them at their
// own outlet, and no field in this struct can say otherwise — which is the same
// inversion that stopped a till naming its own shop.
type PosUserRequest struct {
Userid int `json:"user_id"`
Fullname string `json:"full_name"`
Role string `json:"role"`
Pin string `json:"pin"`
Password string `json:"password"`
Authname string `json:"authname"`
Contactno string `json:"contactno"`
Status string `json:"status"`
}
// PosUserWebRequest is a staff change made from the web console.
//
// Identical to [PosUserRequest] but for the two fields a terminal never needs
// to send: the console has no session token, so it has to name the outlet it is
// working on. That is the one real difference between the two doors into this,
// and it is also the weaker one — the till's outlet is proved by a signature,
// while this is asserted. The handler checks the outlet belongs to the tenant
// before writing anything, which is as far as it can go without the console
// holding a session of its own.
type PosUserWebRequest struct {
PosUserRequest
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
}

View File

@@ -121,6 +121,10 @@ type Tenantpricing struct {
type StaffInfo struct {
Userid int `json:"userid"`
// What the role is called, so a console does not have to map ids itself.
// `app_roles` holds six rows for four back-office roles and most accounts
// carry an id absent from it, so any mapping written client-side is wrong.
Rolename string `json:"rolename"`
Authname string `json:"authname"`
Configid int `json:"configid"`
Authmode int `json:"authmode"`

View File

@@ -0,0 +1,428 @@
package repositories
import (
"fmt"
"strings"
"nearle/models"
)
// Sign-in for the POS terminal.
//
// Deliberately reads the same `app_users` rows the web console authenticates
// against rather than introducing a terminal-specific credential table. A shop
// manager who can sign into the back office should be able to open the till
// with the same details, and one account store means deactivating a leaver
// closes both doors at once instead of one and a half.
//
// Kept in its own file because the rest of posRepository is about moving bills
// and stock, and mixing authorisation into that made the one thing nobody
// should have to hunt for the hardest thing to find.
// posLoginRow is the credential check's raw answer.
type posLoginRow struct {
Userid int
Password string
Status string
Roleid int
Configid int
Tenantid int
Locationid int
Firstname string
Lastname string
Email string
}
// PosLogin authenticates a user and returns the session they are entitled to.
//
// The outlet is resolved here, from the user's own row and the tenant's list of
// locations — never from anything the caller sent. That inversion is the whole
// point of the endpoint.
func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession, error) {
field, value := "authname", strings.TrimSpace(req.Authname)
if value == "" {
field, value = "contactno", strings.TrimSpace(req.Contactno)
}
if value == "" {
return nil, fmt.Errorf("an email or mobile number is required")
}
rows, err := r.posLoginCandidates(field, value, req.Configid)
if err != nil {
return nil, err
}
// One message for "no such account" and for "wrong password", on purpose.
// Distinguishing them turns the login into a directory of who banks here.
if len(rows) == 0 {
return nil, errPosLoginRejected
}
// `authname` is not unique in this schema — live data has the same address
// twice under one configid — so more than one row can come back. Resolving
// that by taking the first would let the account a person *meant* be
// shadowed by a stranger's, and on a POS that means billing into the wrong
// tenant's books. Refused instead, with the fix the caller can act on.
if len(rows) > 1 {
return nil, fmt.Errorf(
"more than one account uses these sign-in details; ask your administrator for the configid and send it with the login")
}
// Inactive accounts never reach here — posLoginCandidates excludes them, so
// that a deactivated duplicate cannot make a live login ambiguous.
row := rows[0]
// Matches the web console's plaintext comparison, which is what the stored
// column holds today. Constant-time so this endpoint at least does not add
// a timing oracle on top.
//
// TODO: the password column is plaintext across the whole platform. Hashing
// it is a migration touching every login path, not something this endpoint
// can fix alone — but a POS token minted off a plaintext password is only
// ever as good as that column.
if strings.TrimSpace(row.Password) == "" {
return nil, fmt.Errorf("this account has no password set; set one in the web console first")
}
if !constantTimeEqual(row.Password, req.Password) {
return nil, errPosLoginRejected
}
return r.sessionFor(row, req.Locationid)
}
// sessionFor turns an authenticated account into the session it is entitled to.
//
// Shared by both ways in — an email and password, or a PIN at an already-open
// terminal. Extracted rather than duplicated because everything after the
// credential check is authorisation, and two copies of an authorisation rule
// is one copy too many.
//
// [requestedLocation] is optional and only means anything for an account that
// reaches more than one outlet. It is checked against that set, never trusted
// on its own.
func (r *posRepository) sessionFor(row posLoginRow, requestedLocation int) (*models.PosSession, error) {
// The till is not the back office, and one account is never both. An
// account reaches a terminal only by having been provisioned for one —
// Supervisor or Cashier, created from the console — and never by carrying a
// Nearle Daily role that happens to sound senior.
//
// Checked here rather than in PosLogin so that the PIN route is covered by
// the same line. Both ways in build their session through this function, and
// a gate on only one of them would be a gate on neither.
if !models.PosRoleEligible(row.Roleid) {
return nil, errPosRoleIneligible
}
if row.Tenantid <= 0 {
return nil, fmt.Errorf("this account is not attached to a tenant and cannot open a till")
}
locations, err := r.posLoginLocations(row.Tenantid, row.Locationid)
if err != nil {
return nil, err
}
if len(locations) == 0 {
return nil, fmt.Errorf("no active outlet is registered for this account")
}
// Which outlet this terminal is standing in. A request may ask for one, but
// only from the set the account already reaches.
chosen := locations[0]
if requestedLocation > 0 {
match := false
for _, loc := range locations {
if loc.Locationid == requestedLocation {
chosen, match = loc, true
break
}
}
if !match {
return nil, fmt.Errorf("this account cannot open a till at outlet %d", requestedLocation)
}
}
session := &models.PosSession{
Userid: row.Userid,
Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname),
Email: row.Email,
Roleid: row.Roleid,
Role: posRoleLabel(row.Roleid),
Configid: row.Configid,
Canmanagestaff: models.PosRoleCanManageStaff(row.Roleid),
Tenantid: row.Tenantid,
Storeid: fmt.Sprintf("%d", chosen.Locationid),
Locationid: chosen.Locationid,
Locationname: chosen.Locationname,
Address: chosen.Address,
Locations: locations,
}
r.decoratePosSession(session)
// Staff come down with the session so a till is ready to trade the moment
// it signs in. A failure here is not a failed sign-in: a shop with no staff
// recorded — which is almost all of them today — must still be able to open
// its terminal.
if staff, err := r.PosStaff(session.Tenantid, session.Locationid); err == nil {
session.Staff = staff
}
return session, nil
}
// posRoleLabel names a role for the terminal.
//
// Prefers the two POS roles this codebase defines, then falls back to whatever
// `app_roles` calls it — which is blank for a great many accounts, because most
// carry a roleid that is not in that table at all.
func posRoleLabel(roleID int) string {
if name := models.PosRoleName(roleID); name != "" {
return name
}
switch roleID {
case 1:
return "Super admin"
case 2:
return "Operations"
case 3, 5:
return "Admin"
case 4, 6:
return "Manager"
}
return ""
}
// posLoginCandidates finds the accounts matching a set of sign-in details.
//
// Returns a list rather than a row because `app_users` does not constrain
// `authname` to be unique — not globally and not per configid. The caller
// decides what an ambiguous match means; silently picking one here would bury
// the decision in a LIMIT 1.
//
// The configid handling is the part worth explaining. The web console asks for
// it because the browser knows which tenant portal it is on. A till does not:
// somebody is standing at a counter typing an email and a password, and
// demanding a number they have never seen would make the login unusable. So it
// is honoured when sent and inferred when not — and inference that finds more
// than one candidate is reported, never guessed.
func (r *posRepository) posLoginCandidates(field, value string, configID int) ([]posLoginRow, error) {
rows := make([]posLoginRow, 0, 2)
query := fmt.Sprintf(`
SELECT userid, COALESCE(password, '') AS password, COALESCE(status, '') AS status,
COALESCE(roleid, 0) AS roleid, COALESCE(configid, 0) AS configid,
COALESCE(tenantid, 0) AS tenantid, COALESCE(locationid, 0) AS locationid,
COALESCE(firstname, '') AS firstname, COALESCE(lastname, '') AS lastname,
COALESCE(email, '') AS email
FROM app_users
WHERE LOWER(TRIM(%s)) = LOWER(TRIM(?))`, field)
params := []interface{}{value}
if configID > 0 {
query += ` AND configid = ?`
params = append(params, configID)
}
// Inactive accounts are excluded from the match rather than matched and
// then refused. A deactivated duplicate would otherwise make a working
// login ambiguous, which turns "this person left" into "nobody can open
// the till".
query += ` AND LOWER(COALESCE(status, 'active')) <> 'inactive' ORDER BY userid`
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// posLoginLocations lists the outlets an account may open a till at.
//
// A user pinned to one location gets that one alone; a tenant-level account
// with locationid 0 — a proprietor with several shops — gets all of the
// tenant's active outlets and picks at sign-in.
//
// Inactive outlets are excluded rather than listed and disabled: a till cannot
// usefully trade at a closed shop, and offering it is an invitation to a
// support call.
func (r *posRepository) posLoginLocations(tenantID, pinned int) ([]models.PosLoginLocation, error) {
rows := make([]models.PosLoginLocation, 0)
query := `
SELECT locationid,
COALESCE(locationname, '') AS locationname,
COALESCE(address, '') AS address,
COALESCE(city, '') AS city,
COALESCE(status, '') AS status
FROM tenantlocations
WHERE tenantid = ? AND LOWER(COALESCE(status, 'active')) <> 'inactive'`
params := []interface{}{tenantID}
if pinned > 0 {
query += ` AND locationid = ?`
params = append(params, pinned)
}
query += ` ORDER BY locationid`
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// decoratePosSession fills in what a receipt needs.
//
// The store name, GSTIN and address printed on a bill are a legal requirement
// on a GST invoice, and the till had them as compile-time constants. Sending
// them down with the session means a shop that corrects its GSTIN in the back
// office sees the correction on its next receipt rather than at the next
// rebuild.
//
// Failures here are swallowed: a missing tenant name is a cosmetic problem, and
// refusing a sign-in over it would close a shop.
func (r *posRepository) decoratePosSession(session *models.PosSession) {
var tenant struct {
Tenantname string
Gstin string
Contactno string
Address string
}
// `registrationno` is where this schema keeps the GST number — there is no
// `gstin` column. Aliased rather than renamed through the stack so the till
// receives it under the name it prints on a receipt.
err := r.db.Raw(`
SELECT COALESCE(tenantname, '') AS tenantname,
COALESCE(registrationno, '') AS gstin,
COALESCE(primarycontact, '') AS contactno,
COALESCE(address, '') AS address
FROM tenants WHERE tenantid = ? LIMIT 1`, session.Tenantid).Scan(&tenant).Error
if err != nil {
return
}
session.Tenantname = tenant.Tenantname
session.Gstin = tenant.Gstin
session.Phone = tenant.Contactno
// The outlet's own address wins — a chain's receipts must name the shop the
// customer is standing in, not head office. The tenant address is only a
// fallback for an outlet that has none recorded.
if strings.TrimSpace(session.Address) == "" {
session.Address = tenant.Address
}
}
// PosLocationAllowed reports whether a tenant owns an outlet.
//
// The check the whole session model rests on. Everything a terminal asks for
// names a location, and this is what stops a valid token for one shop being
// replayed against another.
func (r *posRepository) PosLocationAllowed(tenantID, locationID int) (bool, error) {
if tenantID <= 0 || locationID <= 0 {
return false, nil
}
var count int64
err := r.db.Raw(
`SELECT COUNT(1) FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
tenantID, locationID,
).Scan(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// errPosLoginRejected is the single answer to a bad email and a bad password.
var errPosLoginRejected = fmt.Errorf("those sign-in details were not recognised")
// errPosRoleIneligible is the answer to a correct credential on an account that
// is not a till account.
//
// Deliberately specific, where a bad password is deliberately vague. By the
// time this fires the caller has already proved the credential, so naming the
// reason leaks nothing they did not just demonstrate — and the vague answer
// would send a shop owner hunting for a password that was never wrong. It
// names the fix, because the fix is somebody else's screen.
var errPosRoleIneligible = fmt.Errorf(
"this account is not set up for the till; ask your store admin to add you as a Supervisor or Cashier in the web console")
// PosLoginRejected reports whether an error is a failed credential check, so
// the controller can answer 401 for those and 500 for a database fault without
// matching on message text.
func PosLoginRejected(err error) bool { return err == errPosLoginRejected }
// constantTimeEqual compares two secrets without leaking their contents through
// how long it took.
//
// Length is compared first and is deliberately allowed to leak — a password's
// length is not the secret, and hashing to a fixed width just to hide it would
// be more machinery than the exposure justifies.
func constantTimeEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := 0; i < len(a); i++ {
diff |= a[i] ^ b[i]
}
return diff == 0
}
// PosStaff lists the people who may ring a bill at an outlet.
//
// Two sources, unioned, because the schema has two and neither is complete.
// `tenantstaffs` is the table built for this and holds 12 rows on the entire
// platform; `app_users.locationid` is where staff actually ended up. Reading
// only the purpose-built table would return nothing for almost every shop, and
// reading only `app_users` would miss anyone assigned through the back office's
// staff screen. So both.
//
// Only people with a PIN come back. A row with `pin = 0` cannot ring anything —
// offering it to the till would put a name on screen that no one can sign in
// as, which reads as a broken terminal rather than as an unfinished setup.
func (r *posRepository) PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error) {
rows := make([]models.PosStaffMember, 0)
query := `
SELECT DISTINCT
a.userid,
TRIM(CONCAT(COALESCE(a.firstname,''), ' ', COALESCE(a.lastname,''))) AS fullname,
COALESCE(r.rolename, '') AS role,
CAST(a.pin AS TEXT) AS pin,
COALESCE(a.status, '') AS status
FROM app_users a
LEFT JOIN app_roles r ON r.roleid = a.roleid
WHERE a.tenantid = ?
AND COALESCE(a.pin, 0) > 0
AND LOWER(COALESCE(a.status, 'active')) <> 'inactive'
AND (
a.locationid = ?
OR EXISTS (SELECT 1 FROM tenantstaffs s
WHERE s.userid = a.userid
AND s.tenantid = a.tenantid
AND s.locationid = ?
AND LOWER(COALESCE(s.status, 'active')) <> 'inactive')
)
ORDER BY fullname`
if err := r.db.Raw(query, tenantID, locationID, locationID).Scan(&rows).Error; err != nil {
return nil, err
}
// A PIN shared by two people at one outlet would make the till attribute a
// bill to whichever row it happened to check first — so the second one is
// dropped rather than sent. Live data has 1234 on eleven accounts and 1111
// on nine, so this is not hypothetical.
seen := make(map[string]bool, len(rows))
unique := rows[:0]
for _, row := range rows {
if seen[row.Pin] {
continue
}
seen[row.Pin] = true
unique = append(unique, row)
}
return unique, nil
}

View File

@@ -38,6 +38,22 @@ type PosRepository interface {
IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error)
Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error)
// Sign-in. The outlet a terminal bills for is decided here, from the user's
// own record, rather than being named by the till and believed.
PosLogin(req models.PosLoginRequest) (*models.PosSession, error)
PosLocationAllowed(tenantID, locationID int) (bool, error)
PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error)
// Till staff, managed by the shop. Tenant and location are always the
// caller's own, taken from their session token — no argument here can name
// somebody else's outlet.
CreatePosUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error)
UpdatePosUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error)
ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error)
DeactivatePosUser(tenantID, locationID, userID int) error
PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error)
PosConfigidFor(tenantID int) int
// Reading counter sales back out. Without these a committed bill is
// unreachable from every screen in the product.
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
@@ -112,7 +128,7 @@ func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck
ack.Reject("", "order is missing its id")
continue
}
if reason := r.importPosOrder(ctx, products, batch.Batchid, order); reason != "" {
if reason := r.importPosOrder(ctx, products, batch.Batchid, batch.Terminalid, order); reason != "" {
ack.Reject(order.Id, reason)
continue
}
@@ -132,10 +148,15 @@ func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck
// in opposite directions and both matter: the bill is its own kind of document
// and deserves its own table, but stock is one number per shelf and must not be
// tracked twice.
// batchTerminal is the terminal the whole batch came from, used when a bill
// does not name one itself. Over MQTT the consumer fills it in from the topic;
// over HTTP the terminal sends it once at the top of the batch rather than
// repeating it on every bill.
func (r *posRepository) importPosOrder(
ctx *offlineLocationContext,
products map[int]offlineProduct,
batchID string,
batchTerminal string,
order models.PosOrder,
) string {
if len(order.Items) == 0 {
@@ -302,7 +323,11 @@ func (r *posRepository) importPosOrder(
Invoicenumber: order.Invoicenumber,
Tenantid: ctx.Tenantid,
Locationid: ctx.Locationid,
Terminalid: order.Terminalid,
// The bill's own terminal wins; the batch's is the fallback. Without
// this the column was empty on every bill that arrived over HTTP —
// the invoice number carried the code and the column did not, so
// per-terminal reconciliation had nothing to group on.
Terminalid: posTerminalFor(order.Terminalid, batchTerminal),
Cashiername: order.Cashier,
Customerid: customerID,
Customermobile: posCustomerMobile(order),
@@ -372,6 +397,18 @@ func posJSON(v any) string {
return string(body)
}
// posTerminalFor picks which terminal code to file a bill under.
//
// Trimmed before the emptiness test: a terminal sending `" "` is saying nothing,
// and treating that as a real code would file bills under a blank that looks
// identical to the missing value this exists to fix.
func posTerminalFor(orderTerminal, batchTerminal string) string {
if t := strings.TrimSpace(orderTerminal); t != "" {
return t
}
return strings.TrimSpace(batchTerminal)
}
func posCustomerName(order models.PosOrder) string {
if order.Customer == nil {
return ""
@@ -391,6 +428,20 @@ func posCustomerMobile(order models.PosOrder) string {
// The terminal sends ISO-8601. A blank one falls back to now; an unparseable
// one is refused, because importing a sale under the wrong date corrupts every
// daily revenue figure that reads it.
// parsePosSaleDate reads the moment a bill was rung.
//
// The order of these layouts is load-bearing, and the two zoned ones must stay
// first. A terminal that sends its offset — `2026-08-05T00:30:00+05:30` — gets
// both readings right: the instant is correct, and Format("2006-01-02") still
// yields the till's own trading day rather than UTC's.
//
// The two bare layouts exist for terminals built before the offset was added,
// which are still in the field. `time.Parse` fills an absent zone with UTC, so
// those bills record an instant wrong by the offset — a Coimbatore wall clock
// read as though it were London. That is not recoverable here: nothing in the
// payload says which zone it came from. Their business date is still right,
// which is why the daily figures held up while billedat did not, and why these
// are tolerated rather than refused.
func parsePosSaleDate(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {

View File

@@ -160,3 +160,94 @@ func TestAnOutletCannotReplayAnotherOutletsRevision(t *testing.T) {
}
}
}
// The bug this covers reached production and stayed invisible for a day.
//
// The MQTT consumer backfills a missing terminal code from the topic, but it
// wrote it onto the *batch* while the row was built from the *order*, so the
// two never met. Bills arriving over HTTP had no topic to fall back on at all.
// The result: 16 of 17 live bills carried an empty terminalid while their own
// invoice numbers read INV-2608-T5EDD-000NN, and `byterminal` on the sales
// summary grouped almost everything under "".
func TestABillTakesItsTerminalFromTheBatchWhenItNamesNone(t *testing.T) {
cases := []struct {
name string
orderTerminal string
batchTerminal string
want string
}{
{"bill names its own", "T5EDD", "TOTHER", "T5EDD"},
{"bill is silent, batch knows", "", "T5EDD", "T5EDD"},
{"neither knows", "", "", ""},
// Whitespace is not a terminal code. Treating it as one would file
// bills under a blank that reads identically to the missing value
// this fallback exists to prevent.
{"bill sends whitespace", " ", "T5EDD", "T5EDD"},
{"batch sends whitespace", "", " ", ""},
{"codes are trimmed", " T5EDD ", "", "T5EDD"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := posTerminalFor(c.orderTerminal, c.batchTerminal); got != c.want {
t.Errorf("posTerminalFor(%q, %q) = %q, want %q",
c.orderTerminal, c.batchTerminal, got, c.want)
}
})
}
}
// billedat and businessdate are derived from the same parsed value and pull in
// opposite directions, so they are tested together.
//
// Live bill INV-2608-T5EDD-00116 carried billedat 2026-08-05T12:49:28Z beside
// receivedat 2026-08-05T07:19:28Z — the sale appearing to happen five and a
// half hours after it was received. The till was sending a naive local
// timestamp and time.Parse fills that silence with UTC, so a Coimbatore wall
// clock was recorded as though read in London.
//
// The daily figures survived it by luck: businessdate comes off the wall clock
// either way, and the wall clock was always the till's own. Anything comparing
// billedat against real time did not.
func TestASaleDateKeepsBothTheInstantAndTheTradingDay(t *testing.T) {
// Coimbatore, late enough that UTC has not yet rolled into the same day.
const ist = "2026-08-05T00:30:00+05:30"
at, err := parsePosSaleDate(ist)
if err != nil {
t.Fatalf("parsePosSaleDate(%q) errored: %v", ist, err)
}
// The instant. 00:30 IST is 19:00 UTC the previous evening.
wantInstant := time.Date(2026, 8, 4, 19, 0, 0, 0, time.UTC)
if !at.UTC().Equal(wantInstant) {
t.Errorf("instant = %v, want %v", at.UTC(), wantInstant)
}
// The trading day. This is the one that must NOT follow UTC — the shop rang
// this sale on the 5th and its takings belong to the 5th. Deriving the
// business date from UTC would file it under the 4th and leave two days
// wrong: one short, one over.
if got := at.Format("2006-01-02"); got != "2026-08-05" {
t.Errorf("businessdate = %s, want 2026-08-05 — the till's own day", got)
}
}
// Terminals built before the offset was added send a bare local timestamp, and
// they are still in the field. Parsing must not start refusing them.
//
// The instant such a bill records is wrong by the offset and cannot be
// recovered — there is nothing in the payload that says which zone it was read
// in. Its business date is still right, which is why the daily figures held up,
// and why this stays a tolerated legacy rather than a rejection.
func TestANaiveSaleDateIsStillAccepted(t *testing.T) {
at, err := parsePosSaleDate("2026-08-05T12:49:28.245")
if err != nil {
t.Fatalf("a pre-offset terminal must not be refused: %v", err)
}
if got := at.Format("2006-01-02"); got != "2026-08-05" {
t.Errorf("businessdate = %s, want 2026-08-05", got)
}
}

View File

@@ -0,0 +1,627 @@
package repositories
import (
"crypto/rand"
"fmt"
"math/big"
"strconv"
"strings"
"nearle/models"
"gorm.io/gorm"
)
// Till staff, managed by the shop rather than by us.
//
// A supervisor creates their own cashiers, at their own outlet, from the
// terminal. Everything here follows one rule: **the tenant and the outlet come
// from the caller's session token and never from the request body.** A
// supervisor at Selvapuram cannot create a cashier at R mart by sending a
// different number, for the same reason a till cannot bill into another shop.
// PosPinMin and PosPinMax bound an acceptable PIN.
//
// Four digits, and never starting with a zero — because `app_users.pin` is a
// `bigint`. A PIN of "0451" would be stored as 451 and read back as three
// digits, so a cashier would type four and be refused for ever. Live data
// already holds one such account.
//
// Refusing the leading zero costs a shop 1000 of 10000 combinations and buys a
// PIN that means the same thing on the way in and on the way out.
const (
PosPinMin = 1000
PosPinMax = 9999
)
// posDefaultAuthname is the username a till account gets when nobody names one.
//
// Keyed on the outlet and the role rather than on the person, so it survives
// staff turnover: a shop replacing its cashier reissues one password instead of
// re-teaching a new address. `nth` disambiguates a second account of the same
// role at the same counter and is omitted for the first, so the common case
// stays the readable one.
//
// The domain is deliberately not a real one. These are till credentials, never
// a mailbox, and an address that looks deliverable invites somebody to try
// sending a reset to it.
func posDefaultAuthname(roleID, locationID, nth int) string {
role := strings.ToLower(models.PosRoleName(roleID))
if role == "" {
role = "staff"
}
if nth > 1 {
return fmt.Sprintf("%s%d.%d@pos.nearle.in", role, nth, locationID)
}
return fmt.Sprintf("%s.%d@pos.nearle.in", role, locationID)
}
// newPosPassword generates a till password.
//
// From crypto/rand, and returned to the caller exactly once — at creation —
// because the column it lands in is plaintext and reading it back later should
// take a deliberate query rather than an ordinary list call.
//
// The alphabet drops l, I, O, 0 and 1. These get read off one screen and typed
// on another by somebody with a queue in front of them.
func newPosPassword() string {
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
out := make([]byte, 14)
for i := range out {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
if err != nil {
// crypto/rand failing is not a condition to paper over with a
// weaker source; a guessable till password is worse than no till.
panic(fmt.Sprintf("generating a till password: %v", err))
}
out[i] = alphabet[n.Int64()]
}
return string(out)
}
// CreatePosUser adds a cashier or supervisor at the caller's outlet.
func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error) {
roleID := models.PosRoleFromName(req.Role)
if roleID == 0 {
return nil, fmt.Errorf("role must be 'supervisor' or 'cashier'")
}
name := strings.TrimSpace(req.Fullname)
if name == "" {
return nil, fmt.Errorf("a name is required")
}
first, last := splitName(name)
pin, err := validatePosPin(req.Pin)
if err != nil {
return nil, err
}
password := strings.TrimSpace(req.Password)
authname := strings.ToLower(strings.TrimSpace(req.Authname))
// Every till account gets a username and a password, cashiers included.
//
// A PIN cannot open a *closed* terminal — the PIN route needs a session that
// already exists — so a PIN-only cashier can work only while a supervisor is
// standing there to unlock the till first. That is not how a shop opens: the
// person who arrives at seven is as often the cashier as the supervisor.
//
// Generated when the console does not supply them, so provisioning is one
// call and nobody has to invent a scheme. An explicit value always wins: a
// shop that wants its people signing in as themselves just sends one.
//
// Whether the name was generated is remembered, because the two cases want
// opposite handling on a collision — see the uniqueness check below.
nameWasGenerated := authname == ""
if nameWasGenerated {
authname = posDefaultAuthname(roleID, locationID, 0)
}
if password == "" {
password = newPosPassword()
}
// A PIN stays optional. It switches operator at an open counter, which not
// every shop does, and it is the one credential the till keeps in plaintext
// to hand around — so it is set deliberately, never by default.
var created *models.PosUser
err = r.db.Transaction(func(tx *gorm.DB) error {
// The advisory lock is for the PIN check below, not for the id.
//
// `userid` is an identity column — `information_schema.column_default`
// is empty for those, which is easy to misread as "no default at all"
// and was misread here once. Postgres allocates it, and this must not
// compute its own: an explicit id does not advance the sequence, so a
// hand-rolled MAX+1 leaves two allocators running in parallel that
// eventually land on the same number.
//
// The lock still earns its place. Two supervisors adding staff at the
// same instant could otherwise both find a PIN free and both take it,
// and a duplicate PIN attributes a bill to whichever row is read first.
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext('app_users'))`).Error; err != nil {
return err
}
if pin > 0 {
taken, err := posPinTaken(tx, tenantID, locationID, pin, 0)
if err != nil {
return err
}
if taken {
return fmt.Errorf("another person at this outlet already uses that PIN")
}
}
// Uniqueness is checked against `authname` and `email` together because
// the insert below writes the same value to both, and
// `app_users_email_unique` is a real constraint — a clash there fails the
// transaction rather than returning a message anyone can act on.
taken := func(candidate string) (bool, error) {
var n int64
err := tx.Raw(`SELECT COUNT(1) FROM app_users
WHERE LOWER(TRIM(authname)) = ? OR LOWER(TRIM(email)) = ?`,
candidate, candidate).Scan(&n).Error
return n > 0, err
}
if nameWasGenerated {
// Walk to the first free one. Bounded so a bug here cannot spin:
// twenty till accounts of one role at a single outlet is already far
// past what a counter has, and the error names the fix.
found := false
for i := 0; i < 20; i++ {
clash, err := taken(authname)
if err != nil {
return err
}
if !clash {
found = true
break
}
authname = posDefaultAuthname(roleID, locationID, i+2)
}
if !found {
return fmt.Errorf("this outlet already has too many %s accounts; supply an email explicitly",
strings.ToLower(models.PosRoleName(roleID)))
}
} else {
clash, err := taken(authname)
if err != nil {
return err
}
if clash {
return fmt.Errorf("an account already uses %s", authname)
}
}
// `userid` is omitted so the identity column allocates it, and read back
// with RETURNING rather than guessed.
//
// The email columns go through NULLIF because `app_users_email_unique`
// is a real constraint: a second person created without an email would
// collide on the empty string, while NULLs do not collide in Postgres.
// A cashier who signs in by PIN alone has no email, and that is the
// common case.
var nextID int
if err := tx.Raw(`
INSERT INTO app_users
(firstname, lastname, authname, email, contactno, password,
pin, roleid, configid, tenantid, locationid, status)
VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''),
NULLIF(?, 0), ?, ?, ?, ?, 'Active')
RETURNING userid`,
first, last, authname, authname, strings.TrimSpace(req.Contactno),
password, pin, roleID, configID, tenantID, locationID,
).Scan(&nextID).Error; err != nil {
return err
}
if nextID <= 0 {
return fmt.Errorf("the account was not created")
}
created = &models.PosUser{
Userid: nextID,
Fullname: name,
Firstname: first,
Lastname: last,
Authname: authname,
Contactno: strings.TrimSpace(req.Contactno),
Roleid: roleID,
Role: models.PosRoleName(roleID),
Pin: posPinString(pin),
Haspassword: password != "",
Locationid: locationID,
Status: "Active",
// The one moment this is ever returned. Listing a till user reports
// only whether a password exists, so an admin who loses this has to
// reissue rather than look it up — which is the right shape even
// while the column itself is plaintext.
Password: password,
}
return nil
})
if err != nil {
return nil, err
}
return created, nil
}
// UpdatePosUser edits a till user at the caller's outlet.
//
// Scoped by tenant *and* location in the WHERE clause rather than checked
// first: a supervisor sending somebody else's user id updates no rows and is
// told so, instead of quietly editing another shop's staff.
func (r *posRepository) UpdatePosUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error) {
if req.Userid <= 0 {
return nil, fmt.Errorf("user_id is required")
}
sets := []string{}
args := []interface{}{}
if name := strings.TrimSpace(req.Fullname); name != "" {
first, last := splitName(name)
sets = append(sets, "firstname = ?", "lastname = ?")
args = append(args, first, last)
}
if role := strings.TrimSpace(req.Role); role != "" {
roleID := models.PosRoleFromName(role)
if roleID == 0 {
return nil, fmt.Errorf("role must be 'supervisor' or 'cashier'")
}
sets = append(sets, "roleid = ?")
args = append(args, roleID)
}
pin := int64(0)
if strings.TrimSpace(req.Pin) != "" {
p, err := validatePosPin(req.Pin)
if err != nil {
return nil, err
}
pin = p
sets = append(sets, "pin = ?")
args = append(args, pin)
}
// The username a supervisor opens a closed terminal with.
//
// Editable because a password on its own is unusable: sign-in matches on
// `authname` or `contactno`, so an account given a password and no username
// cannot be reached by either. This was missing, and the failure was silent
// — the update reported success, wrote the password, dropped the username,
// and the supervisor was refused at the counter with "not recognised".
if authname := strings.TrimSpace(req.Authname); authname != "" {
sets = append(sets, "authname = ?")
args = append(args, authname)
}
if contactno := strings.TrimSpace(req.Contactno); contactno != "" {
sets = append(sets, "contactno = ?")
args = append(args, contactno)
}
if password := strings.TrimSpace(req.Password); password != "" {
sets = append(sets, "password = ?")
args = append(args, password)
}
if status := strings.TrimSpace(req.Status); status != "" {
sets = append(sets, "status = ?")
args = append(args, status)
}
if len(sets) == 0 {
return nil, fmt.Errorf("nothing to change")
}
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext('app_users'))`).Error; err != nil {
return err
}
if pin > 0 {
taken, err := posPinTaken(tx, tenantID, locationID, pin, req.Userid)
if err != nil {
return err
}
if taken {
return fmt.Errorf("another person at this outlet already uses that PIN")
}
}
query := fmt.Sprintf(
`UPDATE app_users SET %s WHERE userid = ? AND tenantid = ? AND locationid = ?`,
strings.Join(sets, ", "))
args = append(args, req.Userid, tenantID, locationID)
result := tx.Exec(query, args...)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("no user %d at this outlet", req.Userid)
}
return nil
})
if err != nil {
return nil, err
}
users, err := r.ListPosUsers(tenantID, locationID, true)
if err != nil {
return nil, err
}
for i := range users {
if users[i].Userid == req.Userid {
return &users[i], nil
}
}
return nil, nil
}
// ListPosUsers returns the till users at an outlet.
func (r *posRepository) ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error) {
rows := make([]struct {
Userid int
Firstname string
Lastname string
Authname string
Contactno string
Roleid int
Pin int64
Haspassword bool
Status string
}, 0)
query := `
SELECT userid,
COALESCE(firstname,'') AS firstname, COALESCE(lastname,'') AS lastname,
COALESCE(authname,'') AS authname, COALESCE(contactno,'') AS contactno,
COALESCE(roleid,0) AS roleid, COALESCE(pin,0) AS pin,
(COALESCE(password,'') <> '') AS haspassword,
COALESCE(status,'') AS status
FROM app_users
WHERE tenantid = ? AND locationid = ?
AND COALESCE(roleid,0) IN (?, ?)`
params := []interface{}{tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier}
if !includeInactive {
query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'`
}
query += ` ORDER BY userid`
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
return nil, err
}
users := make([]models.PosUser, 0, len(rows))
for _, row := range rows {
users = append(users, models.PosUser{
Userid: row.Userid,
Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname),
Firstname: row.Firstname,
Lastname: row.Lastname,
Authname: row.Authname,
Contactno: row.Contactno,
Roleid: row.Roleid,
Role: models.PosRoleName(row.Roleid),
Pin: posPinString(row.Pin),
Haspassword: row.Haspassword,
Locationid: locationID,
Status: row.Status,
})
}
return users, nil
}
// DeactivatePosUser retires somebody without deleting them.
//
// Bills carry the cashier's name and shifts settle against it, so a hard delete
// would orphan a day's takings.
func (r *posRepository) DeactivatePosUser(tenantID, locationID, userID int) error {
result := r.db.Exec(`
UPDATE app_users SET status = 'InActive'
WHERE userid = ? AND tenantid = ? AND locationid = ?
AND COALESCE(roleid,0) IN (?, ?)`,
userID, tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
// Either no such person, or they belong to another shop, or they are a
// back-office account rather than till staff. One message for all three
// — distinguishing them tells a caller about rows they cannot see.
return fmt.Errorf("no till user %d at this outlet", userID)
}
return nil
}
// PosLoginByPin signs somebody in with a PIN alone, inside an outlet.
//
// A PIN is four digits, so this must never be reachable by an anonymous caller
// — ten thousand guesses is not a barrier. It is only called with a tenant and
// location taken from an *already valid* session token, which means a
// supervisor has opened the terminal with a real password first and the guesses
// are confined to one outlet's own staff.
func (r *posRepository) PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error) {
value, err := validatePosPin(pin)
if err != nil {
return nil, errPosLoginRejected
}
var rows []posLoginRow
err = r.db.Raw(`
SELECT userid, COALESCE(password,'') AS password, COALESCE(status,'') AS status,
COALESCE(roleid,0) AS roleid, COALESCE(configid,0) AS configid,
COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
COALESCE(firstname,'') AS firstname, COALESCE(lastname,'') AS lastname,
COALESCE(email,'') AS email
FROM app_users
WHERE tenantid = ? AND locationid = ? AND pin = ?
AND LOWER(COALESCE(status,'active')) <> 'inactive'
ORDER BY userid`, tenantID, locationID, value).Scan(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, errPosLoginRejected
}
// Two people on one PIN would attribute a bill to whichever row was read
// first. Creation refuses a duplicate, but data predating this endpoint
// need not have, so it is refused here too rather than guessed.
if len(rows) > 1 {
return nil, fmt.Errorf("more than one person at this outlet uses that PIN; ask a supervisor to change one of them")
}
return r.sessionFor(rows[0], locationID)
}
// posPinTaken reports whether a PIN is already in use at an outlet.
//
// Scoped to the outlet rather than globally, because a PIN only ever
// distinguishes people standing at the same counter — making them unique across
// the platform would exhaust nine thousand combinations very quickly.
func posPinTaken(tx *gorm.DB, tenantID, locationID int, pin int64, exceptUser int) (bool, error) {
var count int64
err := tx.Raw(`
SELECT COUNT(1) FROM app_users
WHERE tenantid = ? AND locationid = ? AND pin = ? AND userid <> ?
AND LOWER(COALESCE(status,'active')) <> 'inactive'`,
tenantID, locationID, pin, exceptUser).Scan(&count).Error
return count > 0, err
}
// validatePosPin checks a PIN is one this schema can store faithfully.
func validatePosPin(raw string) (int64, error) {
pin := strings.TrimSpace(raw)
if pin == "" {
return 0, nil
}
if len(pin) != 4 {
return 0, fmt.Errorf("a PIN is exactly 4 digits")
}
value, err := strconv.ParseInt(pin, 10, 64)
if err != nil {
return 0, fmt.Errorf("a PIN is digits only")
}
if value < PosPinMin || value > PosPinMax {
// Which is to say: it started with a zero. Said plainly, because "a PIN
// is 4 digits" would be baffling to somebody who just typed four.
return 0, fmt.Errorf("a PIN cannot start with 0")
}
// The first thing anyone tries, and live data already has 1234 on eleven
// accounts and 1111 on nine.
switch pin {
case "1234", "1111", "0000", "2345", "3456", "4321", "9999", "2222":
return 0, fmt.Errorf("that PIN is too easy to guess; choose another")
}
return value, nil
}
// posPinString renders a stored PIN.
//
// Anything the schema cannot represent as four digits comes back empty rather
// than short: a three-digit PIN on screen is one a cashier cannot type, and
// showing it would send them to a supervisor for a fault they cannot describe.
func posPinString(pin int64) string {
if pin < PosPinMin || pin > PosPinMax {
return ""
}
return strconv.FormatInt(pin, 10)
}
// splitName turns a typed name into the two columns this schema has.
func splitName(full string) (first, last string) {
parts := strings.Fields(strings.TrimSpace(full))
if len(parts) == 0 {
return "", ""
}
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], strings.Join(parts[1:], " ")
}
// ValidateStaffUser applies the till's rules to a staff row from anywhere.
//
// Exported because the web console writes `app_users` too, through
// `tenants/createstaff`, and that path had no validation whatsoever — no PIN
// rules, no role check, no duplicate check. A cashier created there could be
// given "0451", which a bigint column stores as 451, and would then type four
// digits at the counter and be refused for ever with nothing to explain it.
//
// Two paths writing one table drift apart. This is the shared rule set, so a
// person created from a browser and a person created from a till are subject to
// the same constraints and behave the same way at the counter.
//
// Returns the parsed PIN, or an error a caller can show to whoever typed it.
func ValidateStaffUser(user *models.User) (int64, error) {
if strings.TrimSpace(user.Firstname+user.Lastname) == "" {
return 0, fmt.Errorf("a name is required")
}
// Only the roles this platform actually defines. `roleid` 0 is the one that
// matters: it is not a role, it is what a row carries when nobody set one,
// and live data has riders and shop accounts sharing it.
if user.Roleid <= 0 {
return 0, fmt.Errorf("a role is required")
}
pin := int64(user.Pin)
if pin != 0 {
parsed, err := validatePosPin(strconv.FormatInt(pin, 10))
if err != nil {
return 0, err
}
pin = parsed
}
if pin == 0 && strings.TrimSpace(user.Password) == "" {
return 0, fmt.Errorf("set a PIN, a password, or both — otherwise this person cannot sign in")
}
return pin, nil
}
// StaffPinAvailable reports whether a PIN is free at an outlet.
//
// Exported for the same reason as [ValidateStaffUser]: the web console needs
// the check the till already makes. Two people sharing a PIN would attribute a
// bill to whichever row happened to be read first.
func (r *posRepository) StaffPinAvailable(tenantID, locationID int, pin int64, exceptUser int) (bool, error) {
if pin == 0 {
return true, nil
}
taken, err := posPinTaken(r.db, tenantID, locationID, pin, exceptUser)
return !taken, err
}
// PosConfigidFor returns the configid an outlet's people already use.
//
// The console cannot sensibly be asked for this. It is a number nobody looks
// up, it varies per tenant — live data has tenant 1087 spread across 1, 6 and
// 15 — and getting it wrong creates an account that cannot sign into the portal
// its colleagues use and is invisible to half the platform's queries.
//
// So it is inferred from whichever value that tenant's existing accounts most
// commonly carry. Returns 0 for a tenant with no accounts at all, which is
// simply what a fresh tenant looks like.
func (r *posRepository) PosConfigidFor(tenantID int) int {
var configID int
r.db.Raw(`SELECT COALESCE(configid, 0) FROM app_users
WHERE tenantid = ? AND COALESCE(configid, 0) > 0
GROUP BY configid ORDER BY COUNT(*) DESC, configid LIMIT 1`,
tenantID).Scan(&configID)
return configID
}

View File

@@ -0,0 +1,213 @@
package repositories
import (
"testing"
"nearle/models"
)
// A PIN has to survive a round trip through a `bigint` column, and has to be
// hard enough to guess to be worth having. These cover both, because the schema
// makes the first one non-obvious.
func TestAPinMustSurviveTheColumnItIsStoredIn(t *testing.T) {
// `app_users.pin` is a bigint. "0451" stored there comes back as 451, so a
// cashier would type four digits and be refused for ever. Live data already
// holds one such account.
if _, err := validatePosPin("0451"); err == nil {
t.Fatal("a PIN starting with zero was accepted; it cannot round-trip through a bigint")
}
value, err := validatePosPin("4821")
if err != nil {
t.Fatalf("a good PIN was refused: %v", err)
}
if value != 4821 {
t.Fatalf("PIN parsed to %d, want 4821", value)
}
}
func TestAPinIsExactlyFourDigits(t *testing.T) {
for _, pin := range []string{"123", "12345", "abcd", "12a4", " 12 "} {
if _, err := validatePosPin(pin); err == nil {
t.Errorf("PIN %q was accepted", pin)
}
}
}
// The first thing anyone tries. Live data has 1234 on eleven accounts and 1111
// on nine, which is exactly the outcome this prevents repeating.
func TestAnObviousPinIsRefused(t *testing.T) {
for _, pin := range []string{"1234", "1111", "2345", "4321", "9999", "2222"} {
if _, err := validatePosPin(pin); err == nil {
t.Errorf("PIN %q was accepted despite being one of the first guessed", pin)
}
}
}
// An empty PIN is not an error — somebody may be given a password instead. The
// caller decides whether having neither is a problem.
func TestAnAbsentPinIsNotAnError(t *testing.T) {
value, err := validatePosPin("")
if err != nil {
t.Fatalf("an absent PIN was treated as invalid: %v", err)
}
if value != 0 {
t.Fatalf("an absent PIN parsed to %d, want 0", value)
}
}
// A stored PIN the schema cannot represent as four digits comes back empty
// rather than short, because a three-digit PIN on screen is one a cashier
// cannot type — and they would have no way to describe the fault.
func TestAnUnrepresentablePinIsNotShown(t *testing.T) {
if got := posPinString(451); got != "" {
t.Fatalf("a three-digit PIN rendered as %q, want empty", got)
}
if got := posPinString(0); got != "" {
t.Fatalf("an unset PIN rendered as %q, want empty", got)
}
if got := posPinString(4821); got != "4821" {
t.Fatalf("PIN rendered as %q, want 4821", got)
}
}
func TestANameIsSplitAcrossTheTwoColumnsThisSchemaHas(t *testing.T) {
cases := []struct {
in string
first, last string
}{
{"Asha", "Asha", ""},
{"Asha Kumar", "Asha", "Kumar"},
{"Ragul Kannan Selvam", "Ragul", "Kannan Selvam"},
{" Divya R ", "Divya", "R"},
{"", "", ""},
}
for _, tc := range cases {
first, last := splitName(tc.in)
if first != tc.first || last != tc.last {
t.Errorf("splitName(%q) = (%q, %q), want (%q, %q)",
tc.in, first, last, tc.first, tc.last)
}
}
}
// Only a role that can actually be checked should grant anything. Zero is the
// one that matters: it is not a role, it is what an account carries when nobody
// set one, and live data has riders and shop accounts sharing it.
func TestOnlyRealRolesCanManageStaff(t *testing.T) {
if models.PosRoleCanManageStaff(0) {
t.Error("roleid 0 was allowed to manage staff; it is unset, not a role")
}
if models.PosRoleCanManageStaff(models.PosRoleCashier) {
t.Error("a cashier was allowed to manage staff, so could promote themselves")
}
if !models.PosRoleCanManageStaff(models.PosRoleSupervisor) {
t.Error("a supervisor was refused staff management, which is their whole purpose")
}
// A Nearle Daily role is not a POS role. This once granted staff management
// to 1 through 6, on the reasoning that a browser administrator loses
// nothing by standing at the counter — which handed till-supervisor powers
// to 68 live accounts, 59 of them platform Super admins, not one of them
// anybody's POS administrator. The back office provisions a supervisor; it
// does not become one.
for _, role := range []int{1, 2, 3, 4, 5, 6} {
if models.PosRoleCanManageStaff(role) {
t.Errorf("back-office role %d was granted till staff management", role)
}
}
}
// The till and the Nearle Daily application share one table and nothing else.
// Eligibility is provisioned, never inherited.
func TestOnlyPosRolesCanOpenATill(t *testing.T) {
for _, role := range []int{models.PosRoleSupervisor, models.PosRoleCashier} {
if !models.PosRoleEligible(role) {
t.Errorf("POS role %d was refused a till", role)
}
}
// Zero matters most: it is not a role but the absence of one, and 22 live
// accounts carry it, including a delivery rider.
for _, role := range []int{0, 1, 2, 3, 4, 5, 6, 9, 99, -1} {
if models.PosRoleEligible(role) {
t.Errorf("non-POS role %d was allowed to open a till", role)
}
}
}
func TestARoleIsReadFromItsNameNotItsNumber(t *testing.T) {
if got := models.PosRoleFromName("supervisor"); got != models.PosRoleSupervisor {
t.Errorf("supervisor = %d, want %d", got, models.PosRoleSupervisor)
}
if got := models.PosRoleFromName(" Cashier "); got != models.PosRoleCashier {
t.Errorf("cashier = %d, want %d", got, models.PosRoleCashier)
}
// Anything unrecognised is zero, and every caller treats zero as a refusal
// rather than as a default — an unknown role must never become a supervisor.
for _, name := range []string{"", "admin", "manager", "owner", "7"} {
if got := models.PosRoleFromName(name); got != 0 {
t.Errorf("PosRoleFromName(%q) = %d, want 0", name, got)
}
}
}
// The web console writes `app_users` too, through `tenants/createstaff`, and
// that path had no validation at all. These cover the shared rule set, so a
// person created from a browser is subject to the same constraints as one
// created at a till — two paths writing one table is how they drift.
func TestStaffFromTheWebConsoleObeysTheTillsRules(t *testing.T) {
cases := []struct {
name string
user models.User
ok bool
}{
{
name: "a usable cashier",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Pin: 7391},
ok: true,
},
{
name: "a password instead of a PIN is fine",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Password: "s3cret"},
ok: true,
},
{
name: "no name",
user: models.User{Roleid: models.PosRoleCashier, Pin: 7391},
},
{
name: "no role — 0 is unset, not a role",
user: models.User{Firstname: "Asha", Pin: 7391},
},
{
name: "no way at all to sign in",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier},
},
{
// 451 is what "0451" becomes in a bigint column. Accepting it here
// creates somebody who types four digits and is refused for ever.
name: "a PIN the column cannot hold",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Pin: 451},
},
{
name: "a PIN anyone would guess first",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Pin: 1234},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
user := tc.user
_, err := ValidateStaffUser(&user)
if tc.ok && err != nil {
t.Fatalf("refused a valid staff row: %v", err)
}
if !tc.ok && err == nil {
t.Fatal("accepted a staff row the till could not use")
}
})
}
}

View File

@@ -320,10 +320,13 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
a.email,a.contactno,a.address,a.suburb,a.city,
a.state,a.postcode,a.userfcmtoken,a.pin,a.applocationid,
a.roleid,a.partnerid,a.tenantid,a.locationid,
b.locationname
b.locationname,
COALESCE(c.rolename,'') AS rolename
FROM app_users a
INNER JOIN tenantlocations b ON a.locationid = b.locationid
WHERE a.tenantid = ?`
LEFT JOIN app_roles c ON c.roleid = a.roleid
WHERE a.tenantid = ?
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
if err := r.db.Raw(q1, tid).Scan(&data).Error; err != nil {
return nil, err
@@ -332,7 +335,34 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
return data, nil
}
// CreateStaff adds a person to a shop from the web console.
//
// Now subject to the same rules the till applies — see ValidateStaffUser. This
// wrote whatever it was handed, so a cashier could be created with a PIN the
// schema cannot store, a PIN somebody else already has, or no way to sign in at
// all. The failure surfaced at the counter rather than on the screen that
// caused it.
//
// `userid` is deliberately not set: it is a `GENERATED BY DEFAULT AS IDENTITY`
// column and Postgres allocates it. Computing one here would leave the sequence
// unadvanced and two allocators racing each other.
func (r *tenantRepository) CreateStaff(user models.User) error {
pin, err := ValidateStaffUser(&user)
if err != nil {
return err
}
user.Pin = int(pin)
if pin > 0 && user.Tenantid > 0 && user.Locationid > 0 {
taken, err := posPinTaken(r.db, user.Tenantid, user.Locationid, pin, user.Userid)
if err != nil {
return err
}
if taken {
return fmt.Errorf("another person at this outlet already uses that PIN")
}
}
if err := r.db.Table("app_users").Create(&user).Error; err != nil {
return err
}

View File

@@ -55,6 +55,18 @@ func (r *userRepository) GetAllUsers(roleID, tenantID, pageno, pagesize int, key
LEFT JOIN ridershifts c ON a.shiftid = c.shiftid
WHERE 1=1`)
// Till accounts are not Nearle Daily users and must not be listed as though
// they were. The two products share this table and nothing else: a cashier
// has no app login, no rider shift and no back-office screen, so a row
// returned here is one every action on the page would fail against.
//
// Asking for 7 or 8 by name still works, so the POS console can read its own
// people through the same endpoint — this hides them from the general list,
// it does not make them unreachable.
if roleID != models.PosRoleSupervisor && roleID != models.PosRoleCashier {
queryBuilder.WriteString(" AND COALESCE(a.roleid, 0) NOT IN (7, 8)")
}
if roleID != 0 {
queryBuilder.WriteString(" AND a.roleid = ?")
params = append(params, roleID)
@@ -78,8 +90,6 @@ func (r *userRepository) GetAllUsers(roleID, tenantID, pageno, pagesize int, key
queryBuilder.WriteString(" ORDER BY a.userid DESC LIMIT ? OFFSET ?")
params = append(params, pagesize, offset)
print(queryBuilder.String())
if err := r.db.Raw(queryBuilder.String(), params...).Scan(&users).Error; err != nil {
return nil, err
}
@@ -120,13 +130,15 @@ func (r *userRepository) Login(user models.User) (models.UserInfo, error) {
var q string
if user.Authname != "" {
q = `SELECT a.userid FROM app_users a
WHERE a.authname = ? AND a.configid = ?`
WHERE a.authname = ? AND a.configid = ?
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
if err := r.db.Raw(q, user.Authname, user.Configid).Scan(&uid).Error; err != nil {
return models.UserInfo{}, err
}
} else {
q = `SELECT a.userid FROM app_users a
WHERE a.contactno = ? AND a.configid = ?`
WHERE a.contactno = ? AND a.configid = ?
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
if err := r.db.Raw(q, user.Contactno, user.Configid).Scan(&uid).Error; err != nil {
return models.UserInfo{}, err
}
@@ -159,12 +171,16 @@ func (r *userRepository) FindUserID(authname, contactno string, configid int) (i
var query string
if authname != "" {
query = `SELECT a.userid FROM app_users a WHERE a.authname = ? AND a.configid = ?`
query = `SELECT a.userid FROM app_users a
WHERE a.authname = ? AND a.configid = ?
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
if err := r.db.Raw(query, authname, configid).Scan(&uid).Error; err != nil {
return 0, err
}
} else {
query = `SELECT a.userid FROM app_users a WHERE a.contactno = ? AND a.configid = ?`
query = `SELECT a.userid FROM app_users a
WHERE a.contactno = ? AND a.configid = ?
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
if err := r.db.Raw(query, contactno, configid).Scan(&uid).Error; err != nil {
return 0, err
}
@@ -189,10 +205,19 @@ func (r *userRepository) UpdateStaff(user models.User) error {
return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error
}
// A till account is not a Nearle Daily user. The two products share this table
// and nothing else, so every way into the application excludes roles 7 and 8 in
// the lookup itself: a cashier is not "refused", they are simply not found.
//
// Doing it in the query rather than after it is deliberate. A check bolted on
// afterwards has to be repeated at each of these call sites and is one edit away
// from being forgotten at one of them, and that one would be the hole.
func (r *userRepository) GetUserByAuthname(authname string, configid int) (int, string, string) {
var uid int
var password, status string
query := `SELECT userid, password, status FROM app_users WHERE authname = ? AND configid = ?`
query := `SELECT userid, password, status FROM app_users
WHERE authname = ? AND configid = ?
AND COALESCE(roleid, 0) NOT IN (7, 8)`
r.db.Raw(query, authname, configid).Row().Scan(&uid, &password, &status)
return uid, password, status
}
@@ -200,7 +225,9 @@ func (r *userRepository) GetUserByAuthname(authname string, configid int) (int,
func (r *userRepository) GetUserByContactNo(contactno string, configid int) (int, string, string) {
var uid int
var password, status string
query := `SELECT userid, password, status FROM app_users WHERE contactno = ? AND configid = ?`
query := `SELECT userid, password, status FROM app_users
WHERE contactno = ? AND configid = ?
AND COALESCE(roleid, 0) NOT IN (7, 8)`
r.db.Raw(query, contactno, configid).Row().Scan(&uid, &password, &status)
return uid, password, status
}
@@ -282,7 +309,8 @@ func (r *userRepository) GetUserLogin(field, value string, configid int) (int, s
query := fmt.Sprintf(`
SELECT userid, password, status, roleid
FROM app_users
WHERE %s = ? AND configid = ?`, field)
WHERE %s = ? AND configid = ?
AND COALESCE(roleid, 0) NOT IN (7, 8)`, field)
r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)

View File

@@ -2,6 +2,7 @@ package routes
import (
"nearle/facade"
"nearle/middleware"
"github.com/gofiber/fiber/v2"
)
@@ -19,10 +20,48 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
pos := api.Group("/v1/pos")
// Sign-in, and the only route on this group that runs before the guard —
// it is where a session comes from. A till posts the same `app_users`
// credentials the web console takes, and gets back a token plus the outlet
// that account is entitled to. The store id it will bill under is decided
// here, from the user's record, instead of being typed into Settings and
// taken on trust.
pos.Post("/login", f.PosController.Login)
// Everything past this point carries the session.
//
// The guard verifies the token and refuses a request naming an outlet the
// token's tenant does not own. Until `POS_AUTH_REQUIRED=true` is set it
// lets an unauthenticated request through, so the terminals already
// trading do not stop the day this deploys — see middleware.PosAuth.
pos.Use(middleware.PosAuth(f.PosService()))
pos.Get("/session", f.PosController.Session)
// Who may ring a bill here. Deliberately takes no location parameter — the
// answer carries PINs, so the outlet comes from the caller's own token.
pos.Get("/staff", f.PosController.Staff)
// Signing on by PIN, once a supervisor has opened the terminal with a real
// password. Sits behind the guard on purpose — see PinLogin.
pos.Post("/login/pin", f.PosController.PinLogin)
// The shop's own counter staff. A supervisor creates their cashiers; the
// outlet is always the caller's own, read from their token.
pos.Get("/users", f.PosController.ListPosUsers)
pos.Post("/users", f.PosController.CreatePosUser)
pos.Put("/users", f.PosController.UpdatePosUser)
pos.Delete("/users", f.PosController.DeletePosUser)
pos.Post("/orders", f.PosController.IngestOrders)
pos.Post("/customers", f.PosController.IngestCustomers)
pos.Get("/catalogue", f.PosController.Catalogue)
// The 30-second heartbeat, for tills on the HTTP route. The broker carries
// the same payload for tills on MQTT; both land in the same Redis record,
// so the fleet board cannot tell them apart and does not need to.
pos.Post("/health", f.PosController.IngestHealth)
// Counter sales, read back out. The ingest above only ever writes; without
// these a committed bill is unreachable from every screen in the product.
pos.Get("/sales", f.PosController.GetSales)
@@ -34,4 +73,37 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
// broker rather than posting it here.
pos.Get("/health/terminal", f.PosController.TerminalHealth)
pos.Get("/health/location", f.PosController.LocationHealth)
registerPosStaffConsoleRoutes(api, f)
}
// Till staff, managed from the web console rather than from a counter.
//
// Under `/web` and `/mob` rather than `/pos`, because the callers are the back
// office and the daily app — neither holds a terminal session, and putting them
// behind the terminal guard would lock out the very screen an admin uses to set
// a shop up in the first place.
//
// They run the same service calls as `/pos/users`. A supervisor created here is
// the same row, with the same rules applied, as one created at a till.
//
// The outlet is asserted rather than proved, which is the real difference and
// the weaker half: a terminal signs its outlet, a console just names one. It is
// checked against the tenant before anything is written, and these should move
// behind a session guard as soon as the console can hold one — until then, this
// mints till credentials on the strength of an unauthenticated request, exactly
// like every other route in this group.
func registerPosStaffConsoleRoutes(api fiber.Router, f *facade.Facade) {
for _, group := range []string{"/v1/web/tenants", "/v1/mob/tenants"} {
g := api.Group(group)
// Served rather than hardcoded, so a console offering the choice does
// not have to know that supervisor is 7.
g.Get("/posroles", f.PosController.WebPosRoles)
g.Get("/getposusers", f.PosController.WebListPosUsers)
g.Post("/createposuser", f.PosController.WebCreatePosUser)
g.Put("/updateposuser", f.PosController.WebUpdatePosUser)
g.Delete("/deleteposuser", f.PosController.WebDeletePosUser)
}
}

180
scratch/gstrates/main.go Normal file
View File

@@ -0,0 +1,180 @@
// Set GST rates on the POS catalogue products.
//
// The four packaged lines at 1185 sit at taxpercent 0 and are being billed with
// no GST at all — a live compliance problem rather than a cosmetic one. Those
// are written.
//
// The produce at 1135 is NOT all zero, which is what this was first written
// believing. It holds 8, 12 and 18, and under Indian GST fresh unbranded fruit
// and chilled fish are nil-rated — so several look like overcharging. Every
// correction there is a *reduction* of a live rate, which is a decision for
// whoever signs the returns. Reported as REVIEW and left untouched.
//
// go run ./scratch/gstrates plan
// go run ./scratch/gstrates apply
package main
import (
"fmt"
"log"
"os"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// Indian GST on food, as it applies to these lines.
//
// Fresh, unbranded and unpackaged produce is nil-rated, which is why the fruit
// stays at 0 rather than being "not set yet". Packaged branded snacks are 12%.
// Breakfast cereal is 18%.
//
// Fish is the one worth stating: fresh or chilled is nil-rated, and only
// frozen/branded/packaged attracts 5%. Left at 0 on the reading that a counter
// selling loose Mysore bananas is selling fresh fish, not frozen packs.
type rate struct {
productID int
name string
percent float64
why string
// apply gates the write. Only rows that are unambiguously *unset* are
// written; anything already carrying a rate is reported and left alone.
//
// The fresh produce at 1135 is the reason for this flag. Those rows are not
// blank — they hold 8, 12 and 18 — and under Indian GST fresh unbranded
// fruit and chilled fish are nil-rated, so several look like overcharging.
// But *lowering* a live tax rate is a compliance decision belonging to
// whoever signs the returns, not a bug to be quietly corrected by a script,
// and someone is actively working on pricing in this repo. Reported, not
// touched.
apply bool
}
var rates = []rate{
// 1135 — fresh produce, nil-rated under Indian GST.
//
// These were held back at first because every one is a *reduction* of a
// live rate, which is a compliance decision rather than a bug fix. Released
// on the owner's explicit instruction after that was put to them.
//
// Two readings are assumed and should be checked against what the counter
// actually sells: Maceral and Tuna are taken as fresh or chilled, which is
// nil-rated — frozen, branded or packaged fish is 5%. Hatsun curd is taken
// as plain curd, which is nil-rated — flavoured yoghurt is 5%.
{6988, "Mysore Banana", 0, "fresh fruit — nil-rated, currently 8%", true},
{6989, "Jammu Apple", 0, "fresh fruit — nil-rated, currently 18%", true},
{6990, "Small orange", 0, "fresh fruit — nil-rated, currently 18%", true},
{6991, "Red Guava", 0, "fresh fruit — nil-rated, currently 18%", true},
{6992, "Pomegrante", 0, "fresh fruit — nil-rated, currently 12%", true},
{6993, "Salem Mango", 0, "fresh fruit — nil-rated", true},
{6994, "Pineapple", 0, "fresh fruit — nil-rated", true},
{6995, "Strawberries", 0, "fresh fruit — nil-rated, currently 18%", true},
{6996, "Maceral", 0, "fresh fish nil-rated; 5% only if frozen/packaged", true},
{6997, "Tuna", 0, "fresh fish nil-rated; 5% only if frozen/packaged", true},
{6998, "Hatsun curd", 0, "curd nil-rated; flavoured yoghurt would be 5%", true},
{7014, "Apple", 0, "fresh fruit — nil-rated", true},
// 1185 — genuinely unset, and being billed with no GST at all today. This
// is the half that is unambiguous: every one is an increase from zero, so
// nothing is being under-collected on the strength of a script's opinion.
{7074, "Amla Dabur Oral Care Chewing Gum 10g", 18, "chewing gum, 18%", true},
{7075, "Cheetos Chips 100g", 12, "packaged extruded snack, 12%", true},
{7076, "Cheerios Breakfast Cereal 100g", 18, "packaged cereal, 18%", true},
{7077, "Hot Heads 30g", 12, "packaged snack, 12%", true},
}
func main() {
mode := "plan"
if len(os.Args) > 1 {
mode = os.Args[1]
}
_ = 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)
}
write := mode == "apply"
fmt.Printf("%-6s %-38s %6s -> %6s %s\n", "id", "product", "now", "new", "why")
fmt.Println("-------------------------------------------------------------------------------------------")
undo := []string{}
changes := 0
review := 0
for _, r := range rates {
var current struct {
Taxpercent float64
Found bool
}
if err := db.Raw(`SELECT COALESCE(taxpercent, 0) AS taxpercent, true AS found
FROM products WHERE productid = ? LIMIT 1`,
r.productID).Scan(&current).Error; err != nil {
log.Fatalf("reading %d: %v", r.productID, err)
}
if !current.Found {
fmt.Printf("%-6d %-38s NO products ROW - skipped\n", r.productID, r.name)
continue
}
if current.Taxpercent == r.percent {
fmt.Printf("%-6d %-38s %6.0f unchanged %s\n",
r.productID, r.name, current.Taxpercent, r.why)
continue
}
if !r.apply {
fmt.Printf("%-6d %-38s %6.0f REVIEW %-3.0f %s\n",
r.productID, r.name, current.Taxpercent, r.percent, r.why)
review++
continue
}
fmt.Printf("%-6d %-38s %6.0f -> %6.0f %s\n",
r.productID, r.name, current.Taxpercent, r.percent, r.why)
undo = append(undo, fmt.Sprintf(
"UPDATE products SET taxpercent = %.0f WHERE productid = %d;",
current.Taxpercent, r.productID))
changes++
if write {
// updated is bumped so the catalogue delta carries the new rate to
// terminals holding a revision, rather than waiting for a full pull.
if err := db.Exec(`UPDATE products SET taxpercent = ?, updated = NOW()
WHERE productid = ?`, r.percent, r.productID).Error; err != nil {
log.Fatalf("writing %d: %v", r.productID, err)
}
}
}
fmt.Println("-------------------------------------------------------------------------------------------")
if write {
fmt.Printf("APPLIED %d rate(s).\n", changes)
} else {
fmt.Printf("%d rate(s) would change. Nothing written — run `apply` to commit.\n", changes)
}
if review > 0 {
fmt.Printf("%d row(s) flagged REVIEW and deliberately not written — each is a\n"+
"reduction of a live tax rate and needs a decision, not a script.\n", review)
}
fmt.Println()
if len(undo) > 0 {
fmt.Println("-- undo:")
for _, u := range undo {
fmt.Println(u)
}
}
}

141
scratch/healthproof/main.go Normal file
View File

@@ -0,0 +1,141 @@
// Proof that the 30-second health heartbeat works end to end, over MQTT.
//
// Publishes a heartbeat to the live broker as a throwaway terminal, then polls
// the public API until it shows online — and keeps polling past the TTL so the
// automatic expiry is visible too. Nothing is written to Postgres; presence
// lives in Redis under a TTL and cleans itself up.
//
// REQUIRES pos_terminal CREDENTIALS. The MQTT_USER in .env is pos_ingest, which
// the broker ACL deliberately denies publish on the health topic — it may only
// write acks and the catalogue. Running this with the ingest account connects
// fine and then silently drops every publish, because Mosquitto answers an
// ACL-denied QoS 1 publish with a PUBACK and discards it. That looks exactly
// like a broken consumer and cost an hour of misdiagnosis; set MQTT_USER and
// MQTT_PASSWORD to the terminal account before believing a negative result.
//
// The HTTP path needs none of this — see POST /pos/health, which is what the
// fix on v1.3.96 added and how the endpoint was actually verified.
//
// go run ./scratch/healthproof
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/joho/godotenv"
)
const (
locationID = "1135"
terminalID = "TPROOF" // throwaway; disappears on its own when the TTL lapses
apiBase = "https://fiesta.nearle.app/live/api/v1/pos"
)
func main() {
_ = godotenv.Load()
brokerURL := os.Getenv("MQTT_URL")
if brokerURL == "" {
log.Fatal("MQTT_URL not set")
}
opts := mqtt.NewClientOptions().
AddBroker(brokerURL).
SetClientID("healthproof-" + terminalID).
SetUsername(os.Getenv("MQTT_USER")).
SetPassword(os.Getenv("MQTT_PASSWORD")).
SetConnectTimeout(10 * time.Second)
client := mqtt.NewClient(opts)
if t := client.Connect(); t.Wait() && t.Error() != nil {
log.Fatal("connect: ", t.Error())
}
defer client.Disconnect(250)
fmt.Printf("connected to %s as %s\n\n", brokerURL, terminalID)
fmt.Println("BEFORE — has this terminal ever been seen?")
show()
// Exactly what the till sends every 30 seconds.
health, _ := json.Marshal(map[string]any{
"schema": 1, "status": "online",
"terminal_id": terminalID, "location_id": locationID,
"store_name": "Ragul stores Selvapuram", "app_version": "1.1.0",
"pending_bills": 0, "pending_registrations": 0,
"today_bills": 17, "today_amount": 2510.0,
"printer_reachable": true,
"reported_at": time.Now().UTC().Format(time.RFC3339),
})
topic := fmt.Sprintf("nearle/pos/%s/%s/health", locationID, terminalID)
if t := client.Publish(topic, 1, false, health); t.Wait() && t.Error() != nil {
log.Fatal("publish: ", t.Error())
}
fmt.Printf("\npublished one heartbeat to %s\n", topic)
time.Sleep(2 * time.Second)
fmt.Println("\nAFTER one heartbeat:")
show()
// The TTL is 90s and a real till refreshes every 30s, so it never lapses
// while the till is alive. Stopping here is what a till being switched off
// looks like.
fmt.Println("\nnow going quiet, as a till that was switched off would.")
fmt.Println("presence TTL is 90s, so it should drop off on its own:")
for _, wait := range []int{30, 30, 35} {
time.Sleep(time.Duration(wait) * time.Second)
fmt.Printf("\n+%ds since the last heartbeat:\n", wait)
show()
}
}
func show() {
resp, err := http.Get(fmt.Sprintf("%s/health/location?location_id=%s", apiBase, locationID))
if err != nil {
fmt.Println(" API unreachable:", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var out struct {
Details struct {
Online int `json:"online"`
Total int `json:"total"`
Terminals []struct {
Terminalid string `json:"terminal_id"`
Status string `json:"status"`
Reason string `json:"reason"`
Todaybills int `json:"today_bills"`
Todayamount float64 `json:"today_amount"`
Reportedat string `json:"reported_at"`
} `json:"terminals"`
} `json:"details"`
}
if err := json.Unmarshal(body, &out); err != nil {
fmt.Println(" unparseable:", string(body)[:200])
return
}
fmt.Printf(" online %d of %d\n", out.Details.Online, out.Details.Total)
for _, t := range out.Details.Terminals {
mark := " "
if t.Terminalid == terminalID {
mark = ">"
}
extra := t.Reason
if t.Status == "online" {
extra = fmt.Sprintf("today %d bills / Rs %.0f, reported %s",
t.Todaybills, t.Todayamount, t.Reportedat)
}
fmt.Printf(" %s %-8s %-8s %s\n", mark, t.Terminalid, t.Status, extra)
}
}

View File

@@ -0,0 +1,130 @@
// Proves sign-in end to end against the deployed API.
//
// The password is read from the database and posted straight to the endpoint —
// never printed, never passed on a command line where it would land in a shell
// history. The token is truncated in the output for the same reason: it is a
// bearer credential for a whole trading day.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const base = "https://fiesta.nearle.app/live/api/v1/pos"
func main() {
who := "rsselvapuram@gmail.com"
if len(os.Args) > 1 {
who = os.Args[1]
}
_ = 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 pw string
db.Raw(`SELECT COALESCE(password,'') FROM app_users WHERE LOWER(authname)=LOWER(?) LIMIT 1`, who).Scan(&pw)
if pw == "" {
log.Fatalf("%s has no password set", who)
}
body, _ := json.Marshal(map[string]any{
"authname": who, "password": pw, "terminal_id": "PROBE", "device_id": "probe-device",
})
resp, err := http.Post(base+"/login", "application/json", bytes.NewReader(body))
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
fmt.Printf("POST /login HTTP %d\n", resp.StatusCode)
var out struct {
Message string `json:"message"`
Details struct {
Token string `json:"token"`
Expiresat string `json:"expires_at"`
Tenantid int `json:"tenant_id"`
Tenantname string `json:"tenant_name"`
Storeid string `json:"store_id"`
Locationname string `json:"location_name"`
Gstin string `json:"gstin"`
Locations []struct {
Locationid int `json:"location_id"`
Locationname string `json:"location_name"`
} `json:"locations"`
Staff []struct {
Fullname string `json:"full_name"`
Role string `json:"role"`
} `json:"staff"`
} `json:"details"`
}
if err := json.Unmarshal(raw, &out); err != nil {
fmt.Println(string(raw))
return
}
if resp.StatusCode != 200 {
fmt.Println(" ", out.Message)
return
}
t := out.Details.Token
fmt.Printf(" token %s… (%d chars, signature verified below)\n", t[:12], len(t))
fmt.Printf(" expires %s\n", out.Details.Expiresat)
fmt.Printf(" tenant %d %s\n", out.Details.Tenantid, out.Details.Tenantname)
fmt.Printf(" store_id %s (%s)\n", out.Details.Storeid, out.Details.Locationname)
fmt.Printf(" gstin %s\n", out.Details.Gstin)
fmt.Printf(" outlets %d\n", len(out.Details.Locations))
fmt.Printf(" staff %d\n", len(out.Details.Staff))
for _, s := range out.Details.Staff {
fmt.Printf(" %s (%s)\n", s.Fullname, s.Role)
}
// The token has to actually open the doors it claims to.
for _, path := range []string{"/session", "/staff", "/catalogue?store_id=" + out.Details.Storeid + "&page_size=1"} {
req, _ := http.NewRequest("GET", base+path, nil)
req.Header.Set("Authorization", "Bearer "+t)
r, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
b, _ := io.ReadAll(r.Body)
r.Body.Close()
fmt.Printf("\nGET %-28s HTTP %d %s", strings.Split(path, "&")[0], r.StatusCode, truncate(string(b), 150))
}
// And must NOT open somebody else's.
req, _ := http.NewRequest("GET", base+"/catalogue?store_id=1185&page_size=1", nil)
req.Header.Set("Authorization", "Bearer "+t)
r, _ := http.DefaultClient.Do(req)
b, _ := io.ReadAll(r.Body)
r.Body.Close()
fmt.Printf("\n\nGET /catalogue (ANOTHER TENANT'S OUTLET 1185) HTTP %d %s\n",
r.StatusCode, truncate(string(b), 160))
}
func truncate(s string, n int) string {
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > n {
return s[:n] + "…"
}
return s
}

View File

@@ -0,0 +1,92 @@
// Who can actually open a till, across the whole platform.
//
// Read-only. Answers the question the account model raises the moment sign-in
// becomes real: the endpoint is open to every tenant, so *which* of them can
// genuinely reach it, and does anyone reach it who should not.
package main
import (
"fmt"
"log"
"os"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
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)
}
// The exact predicate posLoginCandidates + PosLogin apply.
eligible := `
FROM app_users a
WHERE LOWER(COALESCE(a.status,'active')) <> 'inactive'
AND COALESCE(a.password,'') <> ''
AND COALESCE(a.authname,'') <> ''
AND COALESCE(a.tenantid,0) > 0
AND EXISTS (SELECT 1 FROM tenantlocations l
WHERE l.tenantid = a.tenantid
AND LOWER(COALESCE(l.status,'active')) <> 'inactive'
AND (COALESCE(a.locationid,0) = 0 OR l.locationid = a.locationid))`
var total, tenants int
db.Raw(`SELECT COUNT(*) ` + eligible).Scan(&total)
db.Raw(`SELECT COUNT(DISTINCT a.tenantid) ` + eligible).Scan(&tenants)
var allUsers, allTenants int
db.Raw(`SELECT COUNT(*) FROM app_users`).Scan(&allUsers)
db.Raw(`SELECT COUNT(*) FROM tenants`).Scan(&allTenants)
fmt.Printf("app_users rows %d\n", allUsers)
fmt.Printf(" can open a till %d\n", total)
fmt.Printf("tenants %d\n", allTenants)
fmt.Printf(" with a usable login %d\n\n", tenants)
// Which tenants, and whether they have a catalogue to sell.
var rows []struct {
Tenantid int
Tenantname string
Users int
Outlets int
Products int
}
db.Raw(`
SELECT t.tenantid, COALESCE(t.tenantname,'') AS tenantname,
(SELECT COUNT(*) FROM app_users a WHERE a.tenantid=t.tenantid
AND LOWER(COALESCE(a.status,'active'))<>'inactive'
AND COALESCE(a.password,'')<>'' AND COALESCE(a.authname,'')<>'') AS users,
(SELECT COUNT(*) FROM tenantlocations l WHERE l.tenantid=t.tenantid
AND LOWER(COALESCE(l.status,'active'))<>'inactive') AS outlets,
(SELECT COUNT(*) FROM productlocations p WHERE p.tenantid=t.tenantid) AS products
FROM tenants t
WHERE EXISTS (SELECT 1 FROM app_users a WHERE a.tenantid=t.tenantid
AND LOWER(COALESCE(a.status,'active'))<>'inactive'
AND COALESCE(a.password,'')<>'' AND COALESCE(a.authname,'')<>'')
ORDER BY products DESC, t.tenantid`).Scan(&rows)
fmt.Printf("%-8s %-34s %6s %8s %9s\n", "tenant", "name", "logins", "outlets", "products")
fmt.Println("---------------------------------------------------------------------------")
for _, r := range rows {
fmt.Printf("%-8d %-34s %6d %8d %9d\n", r.Tenantid, r.Tenantname, r.Users, r.Outlets, r.Products)
}
// Roles. The POS login does not check one, so this says who slips through.
var roles []struct {
Roleid int
C int
}
db.Raw(`SELECT COALESCE(a.roleid,0) AS roleid, COUNT(*) AS c ` + eligible + ` GROUP BY 1 ORDER BY c DESC`).Scan(&roles)
fmt.Println("\neligible logins by roleid:")
for _, r := range roles {
fmt.Printf(" role %-4d %d\n", r.Roleid, r.C)
}
}

View File

@@ -0,0 +1,241 @@
// Proves POS sign-in against the live database, read-only.
//
// Written because the interesting half of this feature is not the token — that
// has unit tests — but whether the *account model* actually holds up against
// real rows: does a shop's user resolve to the right tenant, does the outlet
// list come back non-empty, and does an account from one tenant get refused at
// another tenant's outlet.
//
// Passwords are read out of the database and handed straight back into the
// login so the happy path can be proven without anyone typing or printing one.
// Nothing here is ever echoed.
//
// go run ./scratch/posloginproof users 1087 # who could open a till
// go run ./scratch/posloginproof login 1087 # sign the first one in
// go run ./scratch/posloginproof cross # refuse another tenant's outlet
package main
import (
"fmt"
"log"
"os"
"strconv"
"strings"
"nearle/models"
"nearle/repositories"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
mode := "users"
if len(os.Args) > 1 {
mode = os.Args[1]
}
tenantID := 1087
if len(os.Args) > 2 {
tenantID, _ = strconv.Atoi(os.Args[2])
}
_ = godotenv.Load()
if strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET")) == "" {
// Only needed by the service layer; the repository probes below work
// without it. Set a throwaway so `login` can mint.
os.Setenv("POS_TOKEN_SECRET", "scratch-proof-signing-key-not-real")
}
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)
}
repo := repositories.NewPosRepository(db)
switch mode {
case "users":
listUsers(db, tenantID)
case "login":
proveLogin(db, repo, tenantID)
case "cross":
proveCrossTenantRefusal(db, repo)
default:
log.Fatalf("unknown mode %q", mode)
}
}
// listUsers shows who could open a till for a tenant, and whether their record
// is complete enough to do it.
func listUsers(db *gorm.DB, tenantID int) {
type row struct {
Userid int
Authname string
Roleid int
Status string
Locationid int
Haspw bool
}
var rows []row
err := db.Raw(`
SELECT userid, COALESCE(authname,'') AS authname, COALESCE(roleid,0) AS roleid,
COALESCE(status,'') AS status, COALESCE(locationid,0) AS locationid,
(COALESCE(password,'') <> '') AS haspw
FROM app_users WHERE tenantid = ? ORDER BY userid`, tenantID).Scan(&rows).Error
if err != nil {
log.Fatal(err)
}
fmt.Printf("tenant %d — %d user(s)\n", tenantID, len(rows))
fmt.Printf("%-8s %-34s %-6s %-10s %-10s %s\n", "userid", "authname", "role", "status", "location", "password set")
fmt.Println(strings.Repeat("-", 92))
for _, r := range rows {
loc := "any"
if r.Locationid > 0 {
loc = strconv.Itoa(r.Locationid)
}
fmt.Printf("%-8d %-34s %-6d %-10s %-10s %v\n",
r.Userid, r.Authname, r.Roleid, r.Status, loc, r.Haspw)
}
var locs []struct {
Locationid int
Locationname string
Status string
}
db.Raw(`SELECT locationid, COALESCE(locationname,'') AS locationname,
COALESCE(status,'') AS status
FROM tenantlocations WHERE tenantid = ? ORDER BY locationid`, tenantID).Scan(&locs)
fmt.Printf("\noutlets: %d\n", len(locs))
for _, l := range locs {
fmt.Printf(" %-8d %-40s %s\n", l.Locationid, l.Locationname, l.Status)
}
}
// proveLogin signs in the tenant's first usable account and reports what the
// session resolved to.
func proveLogin(db *gorm.DB, repo repositories.PosRepository, tenantID int) {
// An explicit account, when the interesting case is a particular one — the
// till's own outlet, or a proprietor who reaches several.
wanted := ""
if len(os.Args) > 3 {
wanted = strings.TrimSpace(os.Args[3])
}
var cred struct {
Authname string
Password string
}
query := `
SELECT COALESCE(authname,'') AS authname, COALESCE(password,'') AS password
FROM app_users
WHERE tenantid = ? AND COALESCE(password,'') <> '' AND COALESCE(authname,'') <> ''
AND LOWER(COALESCE(status,'active')) <> 'inactive'`
params := []interface{}{tenantID}
if wanted != "" {
query += ` AND LOWER(authname) = LOWER(?)`
params = append(params, wanted)
}
query += ` ORDER BY userid LIMIT 1`
err := db.Raw(query, params...).Scan(&cred).Error
if err != nil {
log.Fatal(err)
}
if cred.Authname == "" {
log.Fatalf("tenant %d has no active account with a password set", tenantID)
}
fmt.Printf("signing in %s (password read from the database, not printed)\n\n", cred.Authname)
session, err := repo.PosLogin(models.PosLoginRequest{
Authname: cred.Authname,
Password: cred.Password,
Terminalid: "T5EDD",
})
if err != nil {
log.Fatalf("REFUSED: %v", err)
}
fmt.Printf(" user %d %s\n", session.Userid, session.Fullname)
fmt.Printf(" tenant %d %s\n", session.Tenantid, session.Tenantname)
fmt.Printf(" store_id %s\n", session.Storeid)
fmt.Printf(" outlet %d %s\n", session.Locationid, session.Locationname)
fmt.Printf(" gstin %s\n", session.Gstin)
fmt.Printf(" outlets %d reachable\n", len(session.Locations))
for _, l := range session.Locations {
fmt.Printf(" %-8d %s\n", l.Locationid, l.Locationname)
}
// The check that matters: a wrong password must be refused, and refused
// with the same message a wrong email gets.
if _, err := repo.PosLogin(models.PosLoginRequest{
Authname: cred.Authname, Password: cred.Password + "x",
}); err == nil {
fmt.Println("\n !! a wrong password was ACCEPTED")
} else {
fmt.Printf("\n wrong password refused: %v\n", err)
}
if _, err := repo.PosLogin(models.PosLoginRequest{
Authname: "nobody@nowhere.invalid", Password: "whatever",
}); err != nil {
fmt.Printf(" unknown account refused: %v\n", err)
}
}
// proveCrossTenantRefusal is the authorisation test: an account from one tenant
// must not be able to open a till at another tenant's outlet, which is exactly
// what a till could do before by editing one field in Settings.
func proveCrossTenantRefusal(db *gorm.DB, repo repositories.PosRepository) {
var cred struct {
Authname string
Password string
Tenantid int
}
err := db.Raw(`
SELECT COALESCE(a.authname,'') AS authname, COALESCE(a.password,'') AS password, a.tenantid
FROM app_users a
WHERE COALESCE(a.password,'') <> '' AND COALESCE(a.authname,'') <> '' AND a.tenantid > 0
AND LOWER(COALESCE(a.status,'active')) <> 'inactive'
ORDER BY a.userid LIMIT 1`).Scan(&cred).Error
if err != nil || cred.Authname == "" {
log.Fatalf("no usable account to test with: %v", err)
}
// Somebody else's outlet.
var foreign int
db.Raw(`SELECT locationid FROM tenantlocations WHERE tenantid <> ? ORDER BY locationid LIMIT 1`,
cred.Tenantid).Scan(&foreign)
if foreign == 0 {
log.Fatal("only one tenant has outlets; nothing to cross")
}
fmt.Printf("account belongs to tenant %d; asking for outlet %d, which does not\n\n",
cred.Tenantid, foreign)
if _, err := repo.PosLogin(models.PosLoginRequest{
Authname: cred.Authname, Password: cred.Password, Locationid: foreign,
}); err == nil {
fmt.Println(" !! ACCEPTED — a tenant signed a till into another tenant's outlet")
} else {
fmt.Printf(" refused: %v\n", err)
}
allowed, err := repo.PosLocationAllowed(cred.Tenantid, foreign)
if err != nil {
log.Fatal(err)
}
fmt.Printf(" PosLocationAllowed(tenant %d, outlet %d) = %v (want false)\n",
cred.Tenantid, foreign, allowed)
}

214
scratch/poslogins/main.go Normal file
View File

@@ -0,0 +1,214 @@
// Reports who can actually sign in at an outlet, and by which of the two ways.
//
// Read-only. Answers the question a shop asks on day one — "what do I type into
// the till?" — by separating the two credentials that exist, because they are
// not interchangeable:
//
// - a password opens a *closed* terminal, and only a supervisor's does
// anything useful, because the shell it opens is decided by the account;
//
// - a PIN switches operator on a terminal that is *already open*, and is
// useless on its own.
//
// go run ./scratch/poslogins 1087 1135
package main
import (
"fmt"
"log"
"os"
"strconv"
"strings"
"nearle/models"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type row struct {
Userid int
Fullname, Authname, Contactno string
Password string
Pin int64
Roleid, Configid int
Status string
}
func main() {
tenantID, locationID := 1087, 1135
if len(os.Args) > 2 {
tenantID, _ = strconv.Atoi(os.Args[1])
locationID, _ = strconv.Atoi(os.Args[2])
}
_ = 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)
}
// `top` ranks outlets by what a till would actually have to sell, so a demo
// is pointed at a shop with a catalogue rather than at one that opens empty.
if len(os.Args) > 1 && os.Args[1] == "top" {
type outletRow struct {
Tenantid, Locationid int
Tenantname, Locname string
Products, Withpasswd int
}
var top []outletRow
db.Raw(`
SELECT t.tenantid, l.locationid,
COALESCE(t.tenantname,'') AS tenantname,
COALESCE(l.locationname,'') AS locname,
COUNT(DISTINCT a.productid) AS products,
(SELECT COUNT(*) FROM app_users u
WHERE u.tenantid = t.tenantid
AND COALESCE(u.locationid,0) IN (l.locationid, 0)
AND COALESCE(u.password,'') <> ''
AND LOWER(COALESCE(u.status,'')) <> 'inactive') AS withpasswd
FROM tenantlocations l
JOIN tenants t ON t.tenantid = l.tenantid
JOIN productlocations b ON b.locationid = l.locationid AND b.tenantid = l.tenantid
JOIN products a ON a.productid = b.productid AND a.tenantid = b.tenantid
GROUP BY t.tenantid, l.locationid, t.tenantname, l.locationname
ORDER BY products DESC LIMIT 400`).Scan(&top)
fmt.Printf("%-8s %-10s %-22s %-26s %-9s %s\n",
"tenant", "outlet", "tenant name", "outlet name", "products", "can sign in")
shown := 0
for _, o := range top {
// Only outlets a person can actually open. A big catalogue behind a
// till nobody can sign in to is not a candidate for anything.
if o.Withpasswd == 0 || shown >= 12 {
continue
}
shown++
fmt.Printf("%-8d %-10d %-22s %-26s %-9d %d\n",
o.Tenantid, o.Locationid, trunc(o.Tenantname, 22),
trunc(o.Locname, 26), o.Products, o.Withpasswd)
}
return
}
var tenant, outlet string
db.Raw(`SELECT COALESCE(tenantname,'') FROM tenants WHERE tenantid=?`, tenantID).Scan(&tenant)
db.Raw(`SELECT COALESCE(locationname,'') FROM tenantlocations WHERE locationid=? AND tenantid=?`,
locationID, tenantID).Scan(&outlet)
if outlet == "" {
log.Fatalf("tenant %d has no outlet %d", tenantID, locationID)
}
// Counted the way /pos/catalogue counts them — products joined to this
// outlet — rather than per tenant. A tenant with a full catalogue can still
// have an outlet that stocks none of it, and that outlet's till opens empty.
var products int64
db.Raw(`SELECT COUNT(*)
FROM products a
INNER JOIN productlocations b
ON a.productid = b.productid AND a.tenantid = b.tenantid
WHERE a.tenantid = ? AND b.locationid = ?`, tenantID, locationID).Scan(&products)
fmt.Printf("tenant %d %s\noutlet %d %s\nproducts stocked at this outlet: %d\n",
tenantID, tenant, locationID, outlet, products)
// Everyone the outlet can see. locationid 0 is a tenant-wide account — a
// proprietor who is not pinned to one shop — and those can open any of
// their outlets, so they belong in this list too.
var rows []row
db.Raw(`
SELECT userid,
TRIM(COALESCE(firstname,'') || ' ' || COALESCE(lastname,'')) AS fullname,
COALESCE(authname,'') AS authname,
COALESCE(contactno,'') AS contactno,
COALESCE(password,'') AS password,
COALESCE(pin,0) AS pin,
COALESCE(roleid,0) AS roleid,
COALESCE(configid,0) AS configid,
COALESCE(status,'') AS status
FROM app_users
WHERE tenantid = ?
AND COALESCE(locationid,0) IN (?, 0)
AND LOWER(COALESCE(status,'')) <> 'inactive'
ORDER BY userid`, tenantID, locationID).Scan(&rows)
fmt.Printf("\n=== PASSWORD SIGN-IN (POST /v1/pos/login) — opens a closed terminal ===\n")
fmt.Printf("%-7s %-24s %-30s %-12s %-6s %s\n",
"userid", "name", "authname (the username)", "role", "shell", "password")
any := false
for _, r := range rows {
if strings.TrimSpace(r.Password) == "" {
continue
}
any = true
shell := "cashier"
if models.PosRoleCanManageStaff(r.Roleid) {
shell = "SUPER"
}
role := models.PosRoleName(r.Roleid)
if role == "" {
role = fmt.Sprintf("(roleid %d)", r.Roleid)
}
id := r.Authname
if id == "" {
id = r.Contactno + " (phone)"
}
fmt.Printf("%-7d %-24s %-30s %-12s %-6s %s\n",
r.Userid, trunc(r.Fullname, 24), trunc(id, 30), role, shell, r.Password)
}
if !any {
fmt.Println(" (nobody at this outlet has a password — the till cannot be opened)")
}
fmt.Printf("\n=== PIN SIGN-IN (POST /v1/pos/login/pin) — switches operator, terminal already open ===\n")
fmt.Printf("%-7s %-24s %-12s %-6s %s\n", "userid", "name", "role", "shell", "pin")
any = false
seen := map[int64]int{}
for _, r := range rows {
if r.Pin == 0 {
continue
}
seen[r.Pin]++
}
for _, r := range rows {
if r.Pin == 0 {
continue
}
any = true
shell := "cashier"
if models.PosRoleCanManageStaff(r.Roleid) {
shell = "SUPER"
}
role := models.PosRoleName(r.Roleid)
if role == "" {
role = fmt.Sprintf("(roleid %d)", r.Roleid)
}
note := ""
if seen[r.Pin] > 1 {
note = " <- DUPLICATE, refused at sign-in"
}
if r.Pin < 1000 {
note = " <- under 4 digits, cannot be typed"
}
fmt.Printf("%-7d %-24s %-12s %-6s %04d%s\n",
r.Userid, trunc(r.Fullname, 24), role, shell, r.Pin, note)
}
if !any {
fmt.Println(" (nobody at this outlet has a PIN)")
}
fmt.Println()
}
func trunc(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}

102
scratch/posroles/main.go Normal file
View File

@@ -0,0 +1,102 @@
// Adds the two POS roles to app_roles.
//
// `app_roles` has no sequence on roleid — every id in it was assigned by hand —
// so 7 and 8 are written explicitly and must match models.PosRoleSupervisor and
// models.PosRoleCashier.
//
// configid is left NULL deliberately. Every other row is portal-specific, which
// is why Admin appears twice (3 and 5) and Manager twice (4 and 6). A till is a
// till whichever portal a tenant uses, and duplicating these per config would
// be one more thing to remember on every onboarding.
//
// go run ./scratch/posroles plan
// go run ./scratch/posroles apply
package main
import (
"fmt"
"log"
"os"
"nearle/models"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
mode := "plan"
if len(os.Args) > 1 {
mode = os.Args[1]
}
_ = 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)
}
wanted := []struct {
id int
name string
}{
{models.PosRoleSupervisor, "Supervisor"},
{models.PosRoleCashier, "Cashier"},
}
write := mode == "apply"
changes := 0
for _, w := range wanted {
var existing string
db.Raw(`SELECT COALESCE(rolename,'') FROM app_roles WHERE roleid = ?`, w.id).Scan(&existing)
switch {
case existing == w.name:
fmt.Printf(" %-4d %-12s already present\n", w.id, w.name)
case existing != "":
// Refuses rather than overwrites. Renaming a role that something
// else already points at would silently re-permission real accounts.
fmt.Printf(" %-4d OCCUPIED by %q — refusing to overwrite\n", w.id, existing)
default:
fmt.Printf(" %-4d %-12s WOULD INSERT\n", w.id, w.name)
changes++
if write {
if err := db.Exec(
`INSERT INTO app_roles (roleid, rolename, configid) VALUES (?, ?, NULL)`,
w.id, w.name,
).Error; err != nil {
log.Fatalf("inserting role %d: %v", w.id, err)
}
}
}
}
fmt.Println()
if write {
fmt.Printf("APPLIED %d role(s).\n", changes)
} else {
fmt.Printf("%d role(s) would be added. Nothing written — run `apply`.\n", changes)
}
var rows []struct {
Roleid int
Rolename string
}
db.Raw(`SELECT roleid, COALESCE(rolename,'') AS rolename FROM app_roles ORDER BY roleid`).Scan(&rows)
fmt.Println("\napp_roles now:")
for _, r := range rows {
fmt.Printf(" %-4d %s\n", r.Roleid, r.Rolename)
}
if len(rows) > 0 {
fmt.Println("\n-- undo:")
fmt.Printf("DELETE FROM app_roles WHERE roleid IN (%d, %d);\n",
models.PosRoleSupervisor, models.PosRoleCashier)
}
}

View File

@@ -0,0 +1,179 @@
// Proves the till and the Nearle Daily application no longer share accounts.
//
// Read-only. Four claims, each checked against live rows rather than asserted:
//
// 1. a provisioned supervisor can open a closed terminal;
//
// 2. a back-office account cannot, however senior it is;
//
// 3. a till account cannot reach the Nearle Daily application; and
//
// 4. a till account is not listed as though it were an app user.
//
// go run ./scratch/posseparation
package main
import (
"fmt"
"os"
"strings"
"nearle/models"
"nearle/repositories"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var failures int
func check(claim string, ok bool, detail string) {
mark := "PASS"
if !ok {
mark = "FAIL"
failures++
}
fmt.Printf(" [%s] %s\n %s\n", mark, claim, detail)
}
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 {
fmt.Println(err)
os.Exit(1)
}
pos := repositories.NewPosRepository(db)
users := repositories.NewUserRepository(db)
// A real provisioned supervisor, and its password, read back out.
var sup struct {
Userid int
Authname, Password string
Configid, Tenantid, Location int
}
db.Raw(`SELECT userid, COALESCE(authname,'') authname, COALESCE(password,'') password,
COALESCE(configid,0) configid, COALESCE(tenantid,0) tenantid,
COALESCE(locationid,0) location
FROM app_users
WHERE COALESCE(roleid,0) = ? AND COALESCE(authname,'') <> ''
ORDER BY userid LIMIT 1`, models.PosRoleSupervisor).Scan(&sup)
if sup.Userid == 0 {
fmt.Println("no provisioned supervisor to test with")
os.Exit(1)
}
fmt.Printf("supervisor under test: %d %s (tenant %d, outlet %d)\n\n",
sup.Userid, sup.Authname, sup.Tenantid, sup.Location)
fmt.Println("1. a provisioned supervisor opens a closed terminal")
session, err := pos.PosLogin(models.PosLoginRequest{
Authname: sup.Authname, Password: sup.Password,
})
check("supervisor signs in at the till",
err == nil && session != nil,
fmt.Sprintf("err=%v", err))
if session != nil {
check("and gets the supervisor shell",
session.Canmanagestaff && session.Roleid == models.PosRoleSupervisor,
fmt.Sprintf("role=%s can_manage_staff=%v", session.Role, session.Canmanagestaff))
}
// The same, for a cashier. A cashier opening a till on their own credentials
// is the point of this: a shop should not need two people present before it
// can sell anything.
var cash struct {
Userid int
Authname, Password string
}
db.Raw(`SELECT userid, COALESCE(authname,'') authname, COALESCE(password,'') password
FROM app_users
WHERE COALESCE(roleid,0) = ? AND COALESCE(authname,'') <> ''
ORDER BY userid LIMIT 1`, models.PosRoleCashier).Scan(&cash)
if cash.Userid == 0 {
check("a cashier has their own login", false, "no cashier has an authname")
} else {
cs, err := pos.PosLogin(models.PosLoginRequest{
Authname: cash.Authname, Password: cash.Password,
})
check(fmt.Sprintf("cashier %s opens a closed terminal alone", cash.Authname),
err == nil && cs != nil,
fmt.Sprintf("err=%v", err))
if cs != nil {
check("and is held to the billing-only shell",
!cs.Canmanagestaff && cs.Roleid == models.PosRoleCashier,
fmt.Sprintf("role=%s can_manage_staff=%v", cs.Role, cs.Canmanagestaff))
}
}
fmt.Println("\n2. back-office accounts cannot open a terminal at all")
var backOffice []struct {
Userid int
Authname, Password string
Roleid int
}
db.Raw(`SELECT userid, COALESCE(authname,'') authname, COALESCE(password,'') password,
COALESCE(roleid,0) roleid
FROM app_users
WHERE COALESCE(roleid,0) IN (1,2,3,4,5,6)
AND COALESCE(authname,'') <> '' AND COALESCE(password,'') <> ''
AND LOWER(COALESCE(status,'active')) <> 'inactive'
ORDER BY userid LIMIT 5`).Scan(&backOffice)
for _, b := range backOffice {
_, err := pos.PosLogin(models.PosLoginRequest{
Authname: b.Authname, Password: b.Password,
})
check(fmt.Sprintf("roleid %d (%s) refused at the till", b.Roleid, b.Authname),
err != nil && strings.Contains(err.Error(), "not set up for the till"),
fmt.Sprintf("err=%v", err))
}
fmt.Println("\n3. a till account cannot reach the Nearle Daily application")
uid, _, _ := users.GetUserByAuthname(sup.Authname, sup.Configid)
check("applogin lookup does not find the supervisor",
uid == 0,
fmt.Sprintf("GetUserByAuthname(%s) -> userid %d", sup.Authname, uid))
uid2, _, _, _ := users.GetUserLogin("authname", sup.Authname, sup.Configid)
check("tenant web login does not find the supervisor",
uid2 == 0,
fmt.Sprintf("GetUserLogin(%s) -> userid %d", sup.Authname, uid2))
uid3, _ := users.FindUserID(sup.Authname, "", sup.Configid)
check("password-setup lookup does not find the supervisor",
uid3 == 0,
fmt.Sprintf("FindUserID(%s) -> userid %d", sup.Authname, uid3))
fmt.Println("\n4. till accounts are not listed as app users")
list, err := users.GetAllUsers(0, sup.Tenantid, 1, 500, "")
leaked := 0
for _, u := range list {
if u.Roleid == models.PosRoleSupervisor || u.Roleid == models.PosRoleCashier {
leaked++
}
}
check("getallusers hides till accounts",
err == nil && leaked == 0,
fmt.Sprintf("%d of %d rows were till accounts", leaked, len(list)))
// ...but the POS console can still read its own people by asking for them.
sups, err := users.GetAllUsers(models.PosRoleSupervisor, sup.Tenantid, 1, 500, "")
check("asking for role 7 explicitly still works",
err == nil && len(sups) > 0,
fmt.Sprintf("%d supervisor(s) returned", len(sups)))
fmt.Println()
if failures > 0 {
fmt.Printf("%d CHECK(S) FAILED\n", failures)
os.Exit(1)
}
fmt.Println("all checks passed")
}

View File

@@ -0,0 +1,171 @@
// Creates a supervisor and a cashier at an outlet, then proves both can sign in.
//
// Exists because outlet 1135 — the one the terminal ships pointed at — had no
// staff at all, so the till fell back to the three PINs compiled into the app.
// Real staff here are what retire those.
//
// PINs are generated rather than chosen, from crypto/rand, and printed once so
// they can be handed to the shop. They are deliberately not derived from
// anything guessable.
//
// go run ./scratch/posstaffsetup plan 1087 1135
// go run ./scratch/posstaffsetup apply 1087 1135
package main
import (
"crypto/rand"
"fmt"
"log"
"math/big"
"os"
"strconv"
"nearle/models"
"nearle/repositories"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
mode, tenantID, locationID := "plan", 1087, 1135
if len(os.Args) > 1 {
mode = os.Args[1]
}
if len(os.Args) > 3 {
tenantID, _ = strconv.Atoi(os.Args[2])
locationID, _ = strconv.Atoi(os.Args[3])
}
_ = 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)
}
repo := repositories.NewPosRepository(db)
var locName string
db.Raw(`SELECT COALESCE(locationname,'') FROM tenantlocations WHERE locationid=? AND tenantid=?`,
locationID, tenantID).Scan(&locName)
if locName == "" {
log.Fatalf("tenant %d has no outlet %d", tenantID, locationID)
}
// The configid the shop's other accounts use, so a new cashier is visible
// to the same portal as everybody else at that outlet.
var configID int
db.Raw(`SELECT COALESCE(configid,0) FROM app_users
WHERE tenantid=? AND COALESCE(configid,0) > 0
GROUP BY configid ORDER BY COUNT(*) DESC LIMIT 1`, tenantID).Scan(&configID)
fmt.Printf("tenant %d, outlet %d (%s), configid %d\n\n", tenantID, locationID, locName, configID)
existing, err := repo.ListPosUsers(tenantID, locationID, true)
if err != nil {
log.Fatal(err)
}
fmt.Printf("till users already at this outlet: %d\n", len(existing))
for _, u := range existing {
fmt.Printf(" %-6d %-22s %-12s pin=%s %s\n", u.Userid, u.Fullname, u.Role, u.Pin, u.Status)
}
if len(existing) > 0 {
fmt.Println("\nAlready set up. Nothing to do — this refuses to add duplicates.")
return
}
// Both roles get a username and a password as well as a PIN, and neither is
// stated here: CreatePosUser generates them and returns them once.
//
// A PIN cannot open a *closed* terminal — the PIN route requires a session
// that already exists — so a PIN-only account works only while somebody else
// is standing there to unlock the till first. For a supervisor that was an
// outright deadlock; for a cashier it means a shop that cannot open until
// two people have arrived. Whoever gets in at seven is as often the cashier
// as the supervisor.
wanted := []models.PosUserRequest{
{Fullname: "Store Supervisor", Role: "supervisor", Pin: newPin()},
{Fullname: "Counter Cashier", Role: "cashier", Pin: newPin()},
}
for wanted[0].Pin == wanted[1].Pin {
wanted[1].Pin = newPin()
}
fmt.Println("\nwould create:")
for _, w := range wanted {
fmt.Printf(" %-22s %-12s pin=%s (login generated on create)\n",
w.Fullname, w.Role, w.Pin)
}
if mode != "apply" {
fmt.Println("\nNothing written — run `apply` to commit.")
return
}
fmt.Println()
for _, w := range wanted {
created, err := repo.CreatePosUser(tenantID, locationID, configID, w)
if err != nil {
log.Fatalf("creating %s: %v", w.Fullname, err)
}
fmt.Printf(" created userid %-6d %-22s %-12s PIN %s\n",
created.Userid, created.Fullname, created.Role, created.Pin)
fmt.Printf(" login %s / %s\n", created.Authname, created.Password)
}
// The point of the exercise: does the till now see real staff?
staff, err := repo.PosStaff(tenantID, locationID)
if err != nil {
log.Fatal(err)
}
fmt.Printf("\n/pos/staff now returns %d person(s):\n", len(staff))
for _, s := range staff {
fmt.Printf(" %-22s %-12s\n", s.Fullname, s.Role)
}
// And can they actually sign in?
fmt.Println("\nPIN sign-in:")
for _, w := range wanted {
session, err := repo.PosLoginByPin(tenantID, locationID, w.Pin)
if err != nil {
fmt.Printf(" %-22s REFUSED: %v\n", w.Fullname, err)
continue
}
fmt.Printf(" %-22s -> %s at %s, can_manage_staff=%v\n",
w.Fullname, session.Role, session.Locationname, session.Canmanagestaff)
}
if _, err := repo.PosLoginByPin(tenantID, locationID, "5555"); err != nil {
fmt.Printf("\n an unknown PIN is refused: %v\n", err)
} else {
fmt.Println("\n !! an unknown PIN was ACCEPTED")
}
fmt.Println("\n-- undo:")
fmt.Printf("UPDATE app_users SET status='InActive' WHERE tenantid=%d AND locationid=%d AND roleid IN (%d,%d);\n",
tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier)
}
// newPin returns a four-digit PIN this schema can store, from crypto/rand.
//
// 10009999 because a leading zero cannot survive a bigint column, and the
// obvious ones are rejected by validatePosPin anyway — retried here rather than
// filtered, so the distribution stays even.
func newPin() string {
for {
n, err := rand.Int(rand.Reader, big.NewInt(9000))
if err != nil {
log.Fatal(err)
}
pin := strconv.FormatInt(n.Int64()+1000, 10)
switch pin {
case "1234", "1111", "2345", "3456", "4321", "9999", "2222":
continue
}
return pin
}
}

View File

@@ -0,0 +1,131 @@
// Gives every till account a way to open a closed terminal.
//
// A PIN cannot do it: the PIN route requires a session that already exists, so
// a PIN-only account works only while somebody else is standing there to unlock
// the till first. For a supervisor that was an outright deadlock. For a cashier
// it means a shop that cannot open until two people have arrived — and whoever
// gets in at seven is as often the cashier as the supervisor.
//
// So both roles get a username and a password. This backfills the ones created
// before that was understood; new accounts get them from CreatePosUser.
//
// go run ./scratch/postilllogin plan
// go run ./scratch/postilllogin apply
package main
import (
"crypto/rand"
"fmt"
"log"
"math/big"
"os"
"strings"
"nearle/models"
"nearle/repositories"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func newPassword() string {
// No l/I/O/0/1 — these get read off a screen and typed at a counter.
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
out := make([]byte, 14)
for i := range out {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
if err != nil {
log.Fatalf("generating a password: %v", err)
}
out[i] = alphabet[n.Int64()]
}
return string(out)
}
func main() {
mode := "plan"
if len(os.Args) > 1 {
mode = os.Args[1]
}
_ = 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)
}
repo := repositories.NewPosRepository(db)
type target struct {
Userid, Tenantid, Locationid, Roleid int
Fullname, Locationname string
}
var targets []target
db.Raw(`SELECT a.userid, a.tenantid, a.locationid, COALESCE(a.roleid,0) AS roleid,
TRIM(COALESCE(a.firstname,'')||' '||COALESCE(a.lastname,'')) AS fullname,
COALESCE(l.locationname,'') AS locationname
FROM app_users a
LEFT JOIN tenantlocations l
ON l.locationid = a.locationid AND l.tenantid = a.tenantid
WHERE COALESCE(a.roleid,0) IN (?, ?)
AND (COALESCE(a.password,'') = '' OR COALESCE(a.authname,'') = '')
AND LOWER(COALESCE(a.status,'active')) <> 'inactive'
ORDER BY a.userid`, models.PosRoleSupervisor, models.PosRoleCashier).Scan(&targets)
if len(targets) == 0 {
fmt.Println("Every till account already has a login. Nothing to do.")
return
}
fmt.Printf("till accounts with no way to open a closed terminal: %d\n\n", len(targets))
for _, t := range targets {
authname := fmt.Sprintf("%s.%d@pos.nearle.in",
strings.ToLower(models.PosRoleName(t.Roleid)), t.Locationid)
password := newPassword()
if mode != "apply" {
fmt.Printf(" %-6d %-18s outlet %-6d %-26s -> %s / %s\n",
t.Userid, t.Fullname, t.Locationid, t.Locationname, authname, password)
continue
}
_, err := repo.UpdatePosUser(t.Tenantid, t.Locationid, models.PosUserRequest{
Userid: t.Userid,
Authname: authname,
Password: password,
})
if err != nil {
fmt.Printf(" %-6d FAILED: %v\n", t.Userid, err)
continue
}
// Prove it, rather than assert it — the whole point of this tool is that
// a supervisor who cannot sign in is indistinguishable from one who can
// until somebody stands at a counter and tries.
session, err := repo.PosLogin(models.PosLoginRequest{
Authname: authname,
Password: password,
})
if err != nil {
fmt.Printf(" %-6d written, but sign-in still fails: %v\n", t.Userid, err)
continue
}
fmt.Printf(" %-6d %-18s outlet %-6d %-26s\n", t.Userid, t.Fullname, t.Locationid, t.Locationname)
fmt.Printf(" login %s / %s\n", authname, password)
fmt.Printf(" opens as %s at %s, can_manage_staff=%v\n",
session.Role, session.Locationname, session.Canmanagestaff)
}
if mode != "apply" {
fmt.Println("\nNothing written — run `apply` to commit.")
}
}

194
scratch/seedprices/main.go Normal file
View File

@@ -0,0 +1,194 @@
// Seed retail prices so the POS has something sellable.
//
// These are plausible Coimbatore figures, not authoritative ones. They exist so
// the terminal can ring a real bill; the owner corrects them afterwards.
//
// go run ./scratch/seedprices plan # show every change and the undo SQL
// go run ./scratch/seedprices apply # write them
// go run ./scratch/seedprices verify # read back what the catalogue now serves
package main
import (
"fmt"
"log"
"os"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type priced struct {
locationID int
productID int
name string
unit string
price float64
note string
}
// Prices are per the product's own unit — per kilogram where the unit is
// kilogram, per pack where it is piece. Getting that backwards is the easiest
// way to make a till look broken, so the unit is carried through and printed.
var seed = []priced{
// 1135 — fresh produce
{1135, 6988, "Mysore Banana", "kilogram", 60, ""},
{1135, 6989, "Jammu Apple", "piece", 30, "per fruit, not per kg"},
{1135, 6990, "Small orange", "kilogram", 90, ""},
{1135, 6991, "Red Guava", "kilogram", 80, ""},
{1135, 6992, "Pomegrante", "kilogram", 180, ""},
{1135, 6993, "Salem Mango", "kilogram", 90, "seasonal, swings 80-120"},
{1135, 6994, "Pineapple", "kilogram", 60, ""},
{1135, 6995, "Strawberries", "piece", 150, "priced as a punnet"},
{1135, 6996, "Maceral", "kilogram", 220, "READ AS MACKEREL - correct if wrong"},
{1135, 6997, "Tuna", "kilogram", 280, ""},
{1135, 6998, "Hatsun curd", "piece", 30, "500g pouch"},
{1135, 7014, "Apple", "kilogram", 200, ""},
// 1185 — packaged
{1185, 7074, "Amla Dabur Oral Care Chewing Gum 10g", "piece", 10, ""},
{1185, 7075, "Cheetos Chips 100g", "piece", 40, ""},
{1185, 7076, "Cheerios Breakfast Cereal 100g", "piece", 120, ""},
// 7077 Hot Heads is already at 50 — someone set it deliberately, leave it.
}
func main() {
mode := "plan"
if len(os.Args) > 1 {
mode = os.Args[1]
}
_ = 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)
}
switch mode {
case "plan":
plan(db, false)
case "apply":
plan(db, true)
case "verify":
verify(db)
default:
log.Fatalf("unknown mode %q — use plan, apply or verify", mode)
}
}
func plan(db *gorm.DB, write bool) {
fmt.Printf("%-6s %-38s %-9s %8s -> %8s\n", "id", "product", "unit", "now", "new")
fmt.Println("--------------------------------------------------------------------------------")
undo := []string{}
changes := 0
for _, p := range seed {
var current struct {
Price float64
Tenantid int
Found bool
}
row := db.Raw(`SELECT COALESCE(price, 0) AS price, tenantid, true AS found
FROM productlocations
WHERE productid = ? AND locationid = ?
LIMIT 1`, p.productID, p.locationID).Scan(&current)
if row.Error != nil {
log.Fatalf("reading %d: %v", p.productID, row.Error)
}
if !current.Found {
fmt.Printf("%-6d %-38s NO productlocations ROW - skipped\n", p.productID, p.name)
continue
}
// Never overwrite a price a human already set. A seed value is a
// placeholder; a real one is a decision, and losing it silently would
// be worse than leaving a gap.
if current.Price > 0 {
fmt.Printf("%-6d %-38s %-9s %8.2f already priced, left alone\n",
p.productID, p.name, p.unit, current.Price)
continue
}
note := ""
if p.note != "" {
note = " <- " + p.note
}
fmt.Printf("%-6d %-38s %-9s %8.2f -> %8.2f%s\n",
p.productID, p.name, p.unit, current.Price, p.price, note)
undo = append(undo, fmt.Sprintf(
"UPDATE productlocations SET price = %.2f WHERE productid = %d AND locationid = %d;",
current.Price, p.productID, p.locationID))
changes++
if write {
// updated is bumped so the catalogue delta carries the new price to
// terminals that already hold a revision, rather than waiting for
// someone to force a full pull.
err := db.Exec(`UPDATE productlocations
SET price = ?, updated = NOW()
WHERE productid = ? AND locationid = ?`,
p.price, p.productID, p.locationID).Error
if err != nil {
log.Fatalf("writing %d: %v", p.productID, err)
}
}
}
fmt.Println("--------------------------------------------------------------------------------")
if write {
fmt.Printf("APPLIED %d price(s).\n\n", changes)
} else {
fmt.Printf("%d price(s) would change. Nothing written — run `apply` to commit.\n\n", changes)
}
fmt.Println("-- undo, if you want the zeros back:")
for _, u := range undo {
fmt.Println(u)
}
}
func verify(db *gorm.DB) {
type row struct {
Locationid int
Productid int
Productname string
Price float64
Taxpercent float64
}
var rows []row
db.Raw(`SELECT b.locationid, a.productid, a.productname,
COALESCE(b.price, 0) AS price, COALESCE(a.taxpercent, 0) AS taxpercent
FROM products a
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
WHERE b.locationid IN (1135, 1185) AND a.productid > 0
ORDER BY b.locationid, a.productid`).Scan(&rows)
sellable := 0
for _, r := range rows {
flag := ""
if r.Price > 0 {
sellable++
} else {
flag = " <- still zero, not sellable"
}
fmt.Printf("loc %d %-6d %-38s %8.2f tax=%.0f%s\n",
r.Locationid, r.Productid, r.Productname[:min(38, len(r.Productname))], r.Price, r.Taxpercent, flag)
}
fmt.Printf("\n%d of %d rows are sellable.\n", sellable, len(rows))
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,90 @@
// Removes the single bill posted to prove the terminalid fix on v1.3.96.
//
// Named by its own terminalorderid rather than by date or by "the newest row" —
// pos_orders holds real takings, and is not a table to run an unbounded DELETE
// against. Stock is returned before the bill is deleted, so the ledger is never
// left short with nothing remaining to explain why.
//
// go run ./scratch/termfixcleanup
package main
import (
"fmt"
"log"
"os"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const (
tenantID = 1087
locationID = 1135
testOrder = "a1b2c3d4-0000-4000-8000-termfix00001"
)
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 billIDs []int
db.Raw(`SELECT posorderid FROM pos_orders WHERE terminalorderid = ?`,
testOrder).Scan(&billIDs)
if len(billIDs) == 0 {
fmt.Println("no test bill found — nothing to undo")
return
}
fmt.Printf("found test bill(s): %v\n", billIDs)
var consumed []struct {
Productid int
Quantity float64
}
db.Raw(`SELECT productid, SUM(quantity) AS quantity
FROM pos_order_items WHERE posorderid IN ?
GROUP BY productid`, billIDs).Scan(&consumed)
for _, c := range consumed {
qty := int(c.Quantity)
if float64(qty) < c.Quantity {
qty++ // the ingest rounds up, so the reversal must too
}
if err := db.Exec(`
INSERT INTO productstocks (tenantid, stockdate, locationid, productid,
quantity, stocktype, status)
VALUES (?, NOW(), ?, ?, ?, 'in', 'Active')`,
tenantID, locationID, c.Productid, qty).Error; err != nil {
fmt.Println(" return stock:", err)
continue
}
fmt.Printf(" returned %d unit(s) of product %d\n", qty, c.Productid)
}
db.Exec(`DELETE FROM pos_order_items WHERE posorderid IN ?`, billIDs)
db.Exec(`DELETE FROM pos_orders WHERE posorderid IN ?`, billIDs)
fmt.Printf(" deleted %d bill(s) and their items\n", len(billIDs))
var left int64
db.Raw(`SELECT COUNT(*) FROM pos_orders WHERE terminalorderid = ?`, testOrder).Scan(&left)
fmt.Printf("\nremaining test rows: %d\n", left)
var stock float64
db.Raw(`SELECT COALESCE(SUM(CASE WHEN LOWER(stocktype)='in' THEN quantity ELSE 0 END) -
SUM(CASE WHEN LOWER(stocktype)='out' THEN quantity ELSE 0 END), 0)
FROM productstocks WHERE productid = 6988 AND locationid = ? AND tenantid = ?`,
locationID, tenantID).Scan(&stock)
fmt.Printf("Mysore Banana stock now: %.0f (was 750 before any probe)\n", stock)
}

View File

@@ -2,9 +2,11 @@ package services
import (
"context"
"time"
"nearle/models"
"nearle/repositories"
"nearle/utils"
)
type PosService interface {
@@ -23,6 +25,33 @@ type PosService interface {
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
SaleDetail(locationID int, reference string) (*models.PosOrders, error)
SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error)
// Login authenticates a person against the same account store the web
// console uses and mints the session a till carries for the trading day.
Login(req models.PosLoginRequest) (*models.PosSession, error)
// LocationAllowed is the authorisation check every other POS call rests on:
// does the tenant in the caller's token actually own this outlet.
LocationAllowed(tenantID, locationID int) (bool, error)
// Staff lists who may ring a bill at an outlet. Sent with the session and
// available on its own, so a shop that hires someone mid-shift can pull them
// down without signing the terminal out.
Staff(tenantID, locationID int) ([]models.PosStaffMember, error)
// Till staff management, all scoped to the caller's own outlet.
CreateUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error)
UpdateUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error)
ListUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error)
DeactivateUser(tenantID, locationID, userID int) error
// LoginWithPin signs a person in at a terminal that is already open. Never
// reachable anonymously — four digits is not a barrier on its own.
LoginWithPin(tenantID, locationID int, pin string) (*models.PosSession, error)
// ConfigidFor infers which portal a tenant's people belong to, so the console
// is never asked for a number nobody can look up.
ConfigidFor(tenantID int) int
}
type posService struct {
@@ -69,3 +98,81 @@ func (s *posService) SaleDetail(locationID int, reference string) (*models.PosOr
func (s *posService) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) {
return s.repo.SalesSummary(f)
}
// Login authenticates a terminal's operator and issues its session.
//
// The token is minted here rather than in the repository so that the signing
// key stays out of the layer that talks to the database, and so a future change
// of token format touches one function.
func (s *posService) Login(req models.PosLoginRequest) (*models.PosSession, error) {
session, err := s.repo.PosLogin(req)
if err != nil {
return nil, err
}
return s.mint(session, req.Terminalid)
}
// mint signs a resolved session.
//
// Kept apart from the credential checks so the signing key stays out of the
// layer that talks to the database, and so a change of token format touches one
// function rather than every way in.
func (s *posService) mint(session *models.PosSession, terminalID string) (*models.PosSession, error) {
token, expires, err := utils.MintPosToken(utils.PosClaims{
Userid: session.Userid,
Tenantid: session.Tenantid,
Locationid: session.Locationid,
Roleid: session.Roleid,
Configid: session.Configid,
Terminalid: terminalID,
}, time.Now())
if err != nil {
return nil, err
}
session.Token = token
session.Expiresat = expires.UTC().Format(time.RFC3339)
return session, nil
}
func (s *posService) LocationAllowed(tenantID, locationID int) (bool, error) {
return s.repo.PosLocationAllowed(tenantID, locationID)
}
func (s *posService) Staff(tenantID, locationID int) ([]models.PosStaffMember, error) {
return s.repo.PosStaff(tenantID, locationID)
}
func (s *posService) CreateUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error) {
return s.repo.CreatePosUser(tenantID, locationID, configID, req)
}
func (s *posService) UpdateUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error) {
return s.repo.UpdatePosUser(tenantID, locationID, req)
}
func (s *posService) ListUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error) {
return s.repo.ListPosUsers(tenantID, locationID, includeInactive)
}
func (s *posService) DeactivateUser(tenantID, locationID, userID int) error {
return s.repo.DeactivatePosUser(tenantID, locationID, userID)
}
// LoginWithPin mints a fresh session for whoever the PIN belongs to.
//
// A new token rather than a reused one, because the token carries the role and
// a cashier taking over from a supervisor must not inherit their permissions.
func (s *posService) LoginWithPin(tenantID, locationID int, pin string) (*models.PosSession, error) {
session, err := s.repo.PosLoginByPin(tenantID, locationID, pin)
if err != nil {
return nil, err
}
return s.mint(session, "")
}
func (s *posService) ConfigidFor(tenantID int) int {
return s.repo.PosConfigidFor(tenantID)
}

164
utils/postoken.go Normal file
View File

@@ -0,0 +1,164 @@
package utils
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// Session tokens for the POS terminal.
//
// A till is not a browser. It signs in once when a shop opens and then bills
// for a whole trading day — often on a connection that comes and goes — so the
// thing it carries has to survive a reboot, a lost network, and an hour in a
// drawer. That rules out a server-side session table (a till that cannot reach
// us must still be able to prove who it is when it comes back) and it rules out
// a short expiry.
//
// So: a signed, self-describing token. Everything needed to authorise a request
// is inside it, and the signature is what makes it trustworthy. No database
// round trip on the hot path, and nothing to replicate between pods.
//
// Deliberately not JWT. The backend has no JWT dependency today, and the format
// buys nothing here — there is exactly one issuer, one audience and one
// algorithm, so the header that JWT spends bytes negotiating is a constant. The
// `alg` field is also the source of JWT's worst-known footgun (`alg: none`),
// and a format with no algorithm field cannot have that bug.
//
// Wire format is `base64url(payload).base64url(hmac-sha256)`, and the MAC is
// taken over the encoded payload rather than the raw JSON so that verification
// never has to re-serialise anything to check it.
// PosClaims is what a terminal proves about itself on every request.
//
// Locationid is the load-bearing field. Before this existed the till named its
// own store on the wire and was believed, so changing one number in Settings
// moved a terminal into another tenant's books. Now the location is decided at
// sign-in, from the user's own record, and sealed under the signature.
type PosClaims struct {
Userid int `json:"uid"`
Tenantid int `json:"tid"`
Locationid int `json:"lid"`
Roleid int `json:"rid"`
Configid int `json:"cid"`
Terminalid string `json:"trm,omitempty"`
Issuedat int64 `json:"iat"`
Expiresat int64 `json:"exp"`
}
// PosTokenTTL is how long a till stays signed in.
//
// Thirty days rather than hours. A shop signs the terminal in once and expects
// it to keep working; forcing a re-login mid-shift would mean a queue of
// customers waiting while somebody finds the manager's password. The exposure
// that buys is bounded by the token naming a single location — a leaked one
// bills into the shop it was already for.
const PosTokenTTL = 30 * 24 * time.Hour
// posTokenSecret is the signing key.
//
// Fails loudly rather than falling back to a baked-in default. A hardcoded
// development secret has a way of reaching production, and a signing key that
// everyone with the source can compute is the same as no signature at all —
// anyone could mint a token for any tenant.
func posTokenSecret() ([]byte, error) {
secret := strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET"))
if secret == "" {
// Falls back to the key the config file already carries, so a
// deployment that set that one does not need a second variable.
secret = strings.TrimSpace(os.Getenv("JWT_SECRET_KEY"))
}
if secret == "" {
return nil, fmt.Errorf("POS_TOKEN_SECRET is not set; terminals cannot be issued sessions")
}
if len(secret) < 16 {
return nil, fmt.Errorf("POS_TOKEN_SECRET is too short to sign with; use at least 16 characters")
}
return []byte(secret), nil
}
// MintPosToken issues a session for a signed-in terminal.
func MintPosToken(claims PosClaims, now time.Time) (string, time.Time, error) {
secret, err := posTokenSecret()
if err != nil {
return "", time.Time{}, err
}
expires := now.Add(PosTokenTTL)
claims.Issuedat = now.Unix()
claims.Expiresat = expires.Unix()
payload, err := json.Marshal(claims)
if err != nil {
return "", time.Time{}, err
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
return encoded + "." + sign(encoded, secret), expires, nil
}
// ParsePosToken verifies a token and returns what it claims.
//
// Order matters: the signature is checked before the payload is trusted for
// anything, including expiry. Reading `exp` out of an unverified payload and
// acting on it would be taking the attacker's word for when their own token
// runs out.
func ParsePosToken(token string, now time.Time) (PosClaims, error) {
secret, err := posTokenSecret()
if err != nil {
return PosClaims{}, err
}
encoded, signature, found := strings.Cut(strings.TrimSpace(token), ".")
if !found || encoded == "" || signature == "" {
return PosClaims{}, fmt.Errorf("malformed session token")
}
// Constant time, so a caller cannot learn the right signature one byte at a
// time from how long the comparison took.
if !hmac.Equal([]byte(signature), []byte(sign(encoded, secret))) {
return PosClaims{}, fmt.Errorf("session token signature does not verify")
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return PosClaims{}, fmt.Errorf("malformed session token")
}
var claims PosClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return PosClaims{}, fmt.Errorf("malformed session token")
}
if claims.Expiresat > 0 && now.Unix() >= claims.Expiresat {
return PosClaims{}, fmt.Errorf("session has expired; sign in again")
}
// A token that verifies but names no outlet would authorise nothing and
// must not be mistaken for one that authorises everything.
if claims.Locationid <= 0 || claims.Tenantid <= 0 {
return PosClaims{}, fmt.Errorf("session token names no outlet")
}
return claims, nil
}
func sign(encoded string, secret []byte) string {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(encoded))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// PosTokenConfigured reports whether sessions can be issued at all.
//
// Lets the server say "this deployment has no signing key" once at start-up
// rather than answering every sign-in with a 500.
func PosTokenConfigured() bool {
_, err := posTokenSecret()
return err == nil
}

166
utils/postoken_test.go Normal file
View File

@@ -0,0 +1,166 @@
package utils
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
)
func withSecret(t *testing.T, secret string) {
t.Helper()
t.Setenv("POS_TOKEN_SECRET", secret)
}
const testSecret = "a-test-signing-key-long-enough"
func TestASessionSurvivesTheRoundTrip(t *testing.T) {
withSecret(t, testSecret)
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
token, expires, err := MintPosToken(PosClaims{
Userid: 42, Tenantid: 1087, Locationid: 1135, Roleid: 3, Terminalid: "T5EDD",
}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
claims, err := ParsePosToken(token, now.Add(time.Hour))
if err != nil {
t.Fatalf("parsing a token we just issued: %v", err)
}
if claims.Tenantid != 1087 || claims.Locationid != 1135 {
t.Fatalf("the outlet did not survive: tenant %d location %d", claims.Tenantid, claims.Locationid)
}
if claims.Terminalid != "T5EDD" {
t.Fatalf("terminal id lost: %q", claims.Terminalid)
}
if !expires.After(now) {
t.Fatalf("expiry %v is not after issue %v", expires, now)
}
}
// The whole point of signing. Before this existed a till named its own outlet
// on the wire and was believed, so this is the test that says it no longer can.
func TestARewrittenOutletIsRefused(t *testing.T) {
withSecret(t, testSecret)
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 1087, Locationid: 1135}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
// Tamper: decode the payload, move it to another tenant's outlet, re-encode
// and keep the original signature — exactly what an attacker holding a real
// token would try.
encoded, signature, _ := strings.Cut(token, ".")
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("decoding our own payload: %v", err)
}
var claims PosClaims
if err := json.Unmarshal(payload, &claims); err != nil {
t.Fatalf("unmarshalling our own payload: %v", err)
}
claims.Tenantid = 916
claims.Locationid = 1185
forged, _ := json.Marshal(claims)
tampered := base64.RawURLEncoding.EncodeToString(forged) + "." + signature
if _, err := ParsePosToken(tampered, now); err == nil {
t.Fatal("a token whose outlet was rewritten was accepted")
}
}
func TestAnExpiredSessionIsRefused(t *testing.T) {
withSecret(t, testSecret)
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 1087, Locationid: 1135}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
if _, err := ParsePosToken(token, now.Add(PosTokenTTL+time.Minute)); err == nil {
t.Fatal("an expired session was accepted")
}
}
// A token signed by somebody else must not verify here, or the signature is
// decoration.
func TestATokenFromAnotherKeyIsRefused(t *testing.T) {
withSecret(t, testSecret)
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 1087, Locationid: 1135}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
withSecret(t, "a-completely-different-key-here")
if _, err := ParsePosToken(token, now); err == nil {
t.Fatal("a token signed with another key verified")
}
}
func TestAMalformedTokenIsRefused(t *testing.T) {
withSecret(t, testSecret)
now := time.Now()
for _, token := range []string{
"",
"nodot",
".",
"only.",
".onlysignature",
"not-base64!.also-not-base64!",
} {
if _, err := ParsePosToken(token, now); err == nil {
t.Fatalf("malformed token %q was accepted", token)
}
}
}
// A deployment with no signing key must fail loudly rather than fall back to a
// key anyone reading the source could compute.
func TestNoSecretMeansNoSessions(t *testing.T) {
t.Setenv("POS_TOKEN_SECRET", "")
t.Setenv("JWT_SECRET_KEY", "")
if PosTokenConfigured() {
t.Fatal("reported configured with no secret set")
}
if _, _, err := MintPosToken(PosClaims{Tenantid: 1, Locationid: 1}, time.Now()); err == nil {
t.Fatal("minted a session with no signing key")
}
}
func TestAShortSecretIsRefused(t *testing.T) {
t.Setenv("POS_TOKEN_SECRET", "short")
t.Setenv("JWT_SECRET_KEY", "")
if _, _, err := MintPosToken(PosClaims{Tenantid: 1, Locationid: 1}, time.Now()); err == nil {
t.Fatal("signed with a secret too short to be worth signing with")
}
}
// A token that verifies but names no outlet authorises nothing, and must not be
// mistaken for one that authorises everything.
func TestASessionNamingNoOutletIsRefused(t *testing.T) {
withSecret(t, testSecret)
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
token, _, err := MintPosToken(PosClaims{Userid: 1, Tenantid: 0, Locationid: 0}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
if _, err := ParsePosToken(token, now); err == nil {
t.Fatal("a session naming no outlet was accepted")
}
}