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>
The offline-sales import required one workbook per outlet and a store
picked in the UI. A merchant running several branches had to download,
fill and upload a file per branch, and the picker defaulted to the
tenant's first outlet — so an admin who never touched it silently
credited the wrong store, which no validation could catch because the
file and the selection agreed with each other.
One workbook now covers every branch. getsaletemplate takes locationid=0
(the default) to span the tenant, stamping tenantid, locationid and the
store name onto every row, and that row's locationid is what decides
which branch a sale is deducted from. The INNER JOIN on tenantlocations
confines it to outlets the tenant owns, so a template can never disclose
another merchant's catalogue.
uploadofflinesales accordingly takes locationid on each bill. The
locationid on the request itself becomes a scope constraint rather than
a destination: left at 0 the bills go where their rows say, and set to a
branch it pins the upload there and refuses anything else. That is what
holds a store user to their own store — the pin comes from their session,
so editing the locationid column in the spreadsheet changes nothing.
Every branch referenced is checked against the tenant regardless.
Branch context and catalogue are resolved once per branch and reused; a
workbook covering six outlets would otherwise re-run both queries for
every bill in it.
Duplicate detection is now per branch. Bill numbers only have to be
unique within a store, since counter books at different outlets
routinely restart numbering at 1, and treating a shared number as a
repeat would have silently dropped a real sale.
Verified against tenant 1087, whose two branches both stock product
6998 at 100 units: a single upload of two bills moved 1097 to 97 and
1135 to 95 independently; the same bill number at both branches imported
as two separate orders; an upload pinned to 1097 imported its own bill
and refused the 1135 one; a row naming another tenant's outlet was
refused; and re-uploading the file deducted nothing. All five test
orders were cancelled afterwards and both branches confirmed back at 100.
Co-Authored-By: Claude Opus 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>
issuperadmin on app_users (schema change already applied to the DB)
is now returned by AppLogin/TenantWebLogin's login response, gated
server-side rather than via a client-supplied roleid. Fixes the login
enrichment query (GetTenantUserById) to LEFT JOIN app_location instead
of INNER JOIN — a tenantless super-admin row was previously silently
dropped, so the login "succeeded" but returned an empty struct.
Also exposes createtenantuser on the web route group (was mob-only).
No repository changes were needed for location auto-provisioning:
GORM was already inserting the nested tenantlocations association on
tenant creation, it just wasn't reachable from the web.
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>