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>
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>
The ingest only ever wrote. A bill that reached pos_orders was safe and
completely unreachable — no screen in the product could show it, and the
only way to see a day's counter takings was to query the database by
hand.
Three endpoints: a paged bill list, one bill with its lines, and a
summary split the three ways somebody actually asks for — by tender for
reconciling a drawer, by day for a chart, by till for an outlet running
several counters.
locationid is required on all of them and is the authorisation boundary,
so a caller cannot page through another shop's takings by omitting a
parameter. Fetching a bill under the wrong outlet returns 404 even when
the reference is a real one.
Dates match businessdate rather than arrival, because a till that was
offline overnight uploads yesterday's bills this morning and they belong
to yesterday. The list is ordered by billedat for the same reason —
sorting by arrival would interleave a recovered backlog through today.
Unlike the ingest handlers these answer in the usual envelope: they are
read by the web app, not by a terminal, and nothing about them is bound
to the till's contract.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
products.productstatus is a per-product lifecycle field holding
"Active"/"Inactive". CreateProductStock overwrote it with "available" on every
stock receipt — an availability value written into a lifecycle column — which
destroyed the real lifecycle state of the rows it touched. 136 products now
read "available" and 12 "outofstock" with no way to recover what they were.
A single column on products cannot express availability anyway: the same
product can be stocked at one outlet and empty at another. That fact belongs
to productlocations.status, which SyncProductLocationStatus already derives
from the ledger, so the receipt path now updates only that and leaves
productstatus alone. UpdateProductStatus remains available as an explicit
admin operation; it is simply no longer called as a side effect of stock
movement.
GetProductCount counted available/outofstock off the same corrupted column and
returned near-nonsense as a result: across 6245 products it matched
'available' on 136 and 'outofstock' on 12, leaving 6097 — the real answer —
uncounted under "Active". It now derives both from the ledger, counting a
product available when it holds positive stock at any of the tenant's outlets,
so total = available + outofstock (6245 = 22 + 6223).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stock shown in the console did not match the productstocks ledger, and two
product endpoints were failing outright. Every cause was on the read side or
in how the availability flag was maintained; the ledger writes themselves
(CreateOrder's "out" entry, cancellation's "in" entry) were already correct.
Read fixes, repositories/productRepository.go:
- GetProductStocks returned SQLSTATE 42803 on every call: bare a.tenantid /
a.stocktype / a.status under GROUP BY a.productid. The per-ledger-row
columns are now aggregated and the grouping covers the identity columns.
- FetchFilteredProducts filtered on an alias `e` that no query defines, so
every /getallproducts call carrying a locationid failed with SQLSTATE 42P01
instead of returning products.
- FetchFilteredProducts joined productlocations on productid alone and joined
a (productid, locationid)-grouped stock subquery on productid alone, so a
product carried by three outlets came back three times, each row showing
another outlet's quantity and status. Both are now tenant-scoped subqueries
collapsed to one row per product and scoped to the outlet when one is given.
- GetProductStocks and FetchFilteredProducts compared stocktype = 'in'
case-sensitively. Production holds 'in' and 'IN' both, so uppercase receipts
were silently dropped from the balance: one outlet reported 0 for a product
holding 50, another reported 0 for twelve products holding 200-840.
- GetStockStatement summed opening over stockdate <= CURRENT_DATE, making it
arithmetically identical to closing. The Inventory ledger showed the same
number in both columns on every row, which reads as stock never moving.
Availability flag:
productlocations.status was maintained by two different rules — the order path
derived it from the balance, the receiving path set 'available' on any "in"
entry regardless of the resulting balance. A partial restock that left the
balance at or below zero marked a product sellable, and a flag set by an old
order never cleared for stock that arrived by a route the API did not own.
Both paths now derive the flag from the live balance through one rule:
SyncProductLocationStatus (receiving side) and syncProductLocationStatus
(order side, inside the caller's transaction). ReactivateProductLocations is
replaced by the former; the service no longer filters refs by stocktype, since
the direction of the movement is no longer what decides the flag. A row that
has already drifted now repairs itself on its next ledger entry.
Verified against the live database: all four stock endpoints return matching
balances, /getallproducts no longer duplicates rows, and the flag sync was
exercised in both directions inside a rolled-back transaction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mobile app needs an "All" tile to browse every product regardless of
category. Rather than inserting a real app_category row (which would break
once any category-scoped product filter treats it as an actual, empty
category), the service now prepends a synthetic entry with categoryid=0 —
reusing the "0 = no category filter" convention GetAllProducts already
implements in FetchFilteredProducts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The store/branch QR code the app scans is just {tenantid, locationid} JSON, but the
onboarding response previously discarded the DB-assigned locationid, so the frontend
had no way to render a store's QR right after onboarding without a separate lookup.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two gaps found while auditing the order/stock flow:
- Receiving stock via an approved stock request only updated
products.productstatus (a global per-product flag). A location
flagged outofstock by CreateOrder never got reactivated, since
nothing touched productlocations.status on the way back in.
CreateProductStock now reactivates the specific
(tenant, location, product) row to "available" for every "in"
entry, the counterpart to how it gets flagged out.
- getproductbyvariant returned no stock info at all, so the app could
only find out a product was unavailable from the 409 at order time.
It now accepts an optional locationid and, when passed, returns
live productstock (same SUM(in)-SUM(out) formula the order check
uses) and locationstatus per product. Omitting locationid keeps the
old response shape.
Added MOBILE_ORDER_VERIFICATION.md as a handoff doc for the mobile
team covering the expected request/response shapes and how to verify
their integration.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
subcategoryid is no longer required to import a catalogue product —
it's a display/grouping hint elsewhere (the codebase already has an
"Uncategorized" fallback for subcategoryid=0), so requiring it was
pure friction with no correctness payoff.
Fixes the category picker at the root: categoryid 2, which tenant
1135's real products actually use, has no row in productcategories at
all (not a filter bug — the master data is genuinely missing it).
Rather than inventing category master data, adds
GET /products/gettenantcategories, which lists categories a tenant's
own products actually use (falling back to a synthesized label when
the master table has no name), so the import category picker always
offers something real instead of an incomplete global list.
Also relaxes the subcategory lookup's tenant filter to include
unowned/global rows (tenantid NULL or 0), not just exact tenant
matches — categoryid 2's real subcategories carry no tenant at all,
so the strict filter was hiding them even when a tenant wanted one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a separate CatalogueDB (pgvector) connection alongside the main
nearledb, plus a new catalogue module (repository/service/controller/
routes) to browse it by brand, category, and keyword, with brand
optional so the whole ~237-product catalogue can be browsed unfiltered.
Adds the actual bridge: importing a catalogue product snapshots it into
the tenant's own products table (keyed on brand+catalogueid, since a
catalogue row's bare id is only unique within its own brand table),
then links it via the existing productlocations upsert. Re-importing
tops up stock and refreshes price instead of duplicating. Also adds an
imported-refs endpoint so the frontend can badge already-imported items
without diffing full product lists, and wires the new AWS S3 image
store used to resolve catalogue product photos.
Bumps Go/Docker to 1.24 for the AWS SDK dependency this needs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>