Commit Graph

15 Commits

Author SHA1 Message Date
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
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
fc81df14e4 Answer the catalogue as a delta when the terminal sends a revision
Every pull was a full snapshot, so a shop with a thousand products
re-sent all of them to correct one price. The response now carries a
revision the terminal stores and hands back, and a pull that supplies
one gets only what moved: the product row, its row at that outlet, or
its stock ledger. Stock is included because a shop's count drifts from a
till's on every sale rung at another counter, and a delta that ignored
it would let that drift persist until someone forced a full pull.

The dangerous part is the flag, not the filter. A response marked
is_delta:false tells the terminal to withdraw every product it does not
mention — so a filtered result carrying that label empties the shelf.
Both are now derived from one value, and there is no path through the
function that filters without also setting the flag.

Everything ambiguous resolves toward the snapshot. A revision that is
malformed, empty, or issued to another outlet yields a zero cutoff and a
complete response; the opposite would leave a terminal permanently
missing changes with nothing to show for it. The revision advances only
on the final page, so a terminal that abandons a paginated pull cannot
end up holding one that claims it saw pages it never received. And the
stamp is taken a second in the past, because a product written during
the same second the query ran would otherwise fall on the wrong side of
the next cutoff and be skipped for good.

A delta still cannot withdraw a deleted product — removing a row from
productlocations leaves no tombstone — so a periodic pull without a
revision is what collects those.

Verified against the live outlet: a full pull of 12, a delta returning
only the one product whose price had changed, and pagination that stays
exact now that productid <= 0 is excluded in SQL rather than after the
LIMIT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:26:01 +05:30
Suriya
64a219e7da Stop tracking .env
It has been in the repository since the initial commit carrying the live
database host, user and password. Removing it from the index stops that
getting worse; the credentials are still in history and should be
rotated, which needs coordinating with everything that reads them.

Deployments should pass configuration as container environment rather
than shipping a file — a file on disk is one `git add -f` away from
being committed again.

Also closes the last untested path: a shopper registration published
over the broker rather than posted over HTTP. All three MQTT topics —
order, customer and health — have now been fired against the live
Mosquitto instance and acknowledged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:56:09 +05:30
Suriya
e3459a0f1c Ingest counter sales from the POS terminals, over MQTT and HTTP
A till holds every bill in its own SQLite database and keeps it for
seven days after we acknowledge it, marking one synced only when its id
comes back in an ack. Everything here follows from that.

Silence is not acceptance, so a failing ingest publishes nothing at all
and the terminal simply sends again. A duplicate is a success, because
at-least-once delivery means a lost ack legitimately re-delivers bills
we already hold, and calling those failures would strand a day of
takings on the till. Deduplication is a unique index on the terminal's
UUID plus an advisory lock held for the transaction.

Bills land in pos_orders / pos_order_items rather than orders: a counter
bill carries a cashier, a terminal, a rounding adjustment, promos,
loyalty movement and a payment split that orders has nowhere to put, and
forcing one into the other loses whatever does not fit. Stock is *not*
split — a counter sale writes the same productstocks rows an app order
does, through helpers extracted from createOrderTx so the rule that
prevents overselling has one implementation rather than two.
GetRevenueSummary and GetSalesSummary were extended to union the new
table in; any new report has to remember the same.

Terminal health goes to Redis under a 90-second TTL, sharing the
instance the express backend uses. A heartbeat is a fact with an expiry
date: a till that loses power stops refreshing and ages off the board by
itself, where a Postgres row would need ~288k writes a day and a reaper.

Proven end to end against the live estate before commit: a bill over
HTTP and one over the real Mosquitto broker, the same bill three times
producing one row and one stock movement, and a heartbeat arriving on
the health endpoint. All probe data was removed afterwards.

Four things that only surfaced against real data. An unset jsonb column
failed the very first bill. Product SKUs are unusable as barcodes — 6,245
products share 93 SKUs and "1" covers 5,794 of them — against the till's
unique index, so barcodes fall back to the product id. A taxpercent of
-1 exists and would have put negative GST in a filed slab. And a product
with id 0 exists, which can never be billed and is now skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:48:00 +05:30
5d9879657b auto lat and long 2026-07-27 11:42:32 +05:30
d4cbf92661 user login 2026-07-22 17:41:02 +05:30
950064de6c productRepository created 2026-07-15 15:25:50 +05:30
Suriya
7e9b3f98ba stores login fix 2026-07-09 18:03:44 +05:30
Suriya
135df9fd18 store users login 2026-07-07 12:09:34 +05:30
Suriya
f220c24c17 new changes 2026-07-01 10:53:57 +05:30