Files
backend_fiesta/models/pos.go
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

460 lines
18 KiB
Go

package models
import "strings"
// Wire format for the Nearle POS terminal.
//
// These types mirror what the till actually publishes, field for field. The
// terminal is the fixed side of this contract: it is installed on a hundred
// machines that cannot all be updated at once, so the names here follow its
// JSON rather than this codebase's usual Go casing.
//
// The authoritative description lives in the terminal repository at
// docs/sync-contract.md.
// PosOrderItem is one line of a counter bill.
//
// Productid arrives as a string because the till stores catalogue ids as text.
// It carries the numeric products.productid this backend issued during a
// catalogue pull, so it parses back to an int on arrival.
type PosOrderItem struct {
Productid string `json:"product_id"`
Barcode string `json:"barcode"`
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Unitprice float64 `json:"unit_price"`
Discount float64 `json:"discount"`
Gstrate float64 `json:"gst_rate"`
Tax float64 `json:"tax"`
Linetotal float64 `json:"line_total"`
}
// PosOrderCustomer is the shopper snapshot carried on the bill itself.
//
// Deliberately thin. The full profile travels on its own uplink; this exists so
// a bill can be attached to somebody even when their registration has not
// arrived yet.
type PosOrderCustomer struct {
Id string `json:"id"`
Mobile string `json:"mobile"`
Name string `json:"name"`
}
// PosOrderPayment is one tender against a bill. A bill may be split across
// several.
type PosOrderPayment struct {
Method string `json:"method"`
Amount float64 `json:"amount"`
Reference string `json:"reference"`
}
// PosOrderPromo records a campaign that fired, as an amount rather than a rule.
// A bill read back years later must show what was actually given, not what
// today's rules would give.
type PosOrderPromo struct {
Id string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Amount float64 `json:"amount"`
}
// PosOrder is one completed sale.
//
// Id is a UUID minted at the till and is the only thing that identifies this
// bill. It is what deduplication keys on, because at-least-once delivery means
// the same bill legitimately arrives more than once.
type PosOrder struct {
Id string `json:"id"`
Invoicenumber string `json:"invoice_number"`
Createdat string `json:"created_at"`
Terminalid string `json:"terminal_id"`
Cashier string `json:"cashier"`
Customer *PosOrderCustomer `json:"customer"`
Subtotal float64 `json:"subtotal"`
Discount float64 `json:"discount"`
Promos []PosOrderPromo `json:"promos"`
Tax float64 `json:"tax"`
Roundoff float64 `json:"round_off"`
Total float64 `json:"total"`
Pointsearned int `json:"points_earned"`
Pointsredeemed int `json:"points_redeemed"`
Payments []PosOrderPayment `json:"payments"`
Items []PosOrderItem `json:"items"`
// GST per slab, as printed on the tax invoice: {"0.05": 12.30, "0.18": 4.50}.
// Absent from terminals built before this field existed, which is why every
// consumer of it has to tolerate an empty map.
Taxbreakdown map[string]float64 `json:"tax_breakdown"`
}
// PosOrderBatch is the envelope a terminal publishes.
//
// Storeid carries the numeric tenantlocations.locationid as a string. The
// tenant is resolved from it server-side and never taken from the terminal — a
// till must not be able to name the tenant it posts into.
type PosOrderBatch struct {
Schema int `json:"schema"`
Batchid string `json:"batch_id"`
Storeid string `json:"store_id"`
Terminalid string `json:"terminal_id"`
Sentat string `json:"sent_at"`
Orders []PosOrder `json:"orders"`
}
// PosCustomer is a shopper registered at a till.
//
// No loyalty figures. Points, lifetime spend and visit counts are derived from
// the bill stream, which is idempotent and sees every counter; accepting a
// terminal's local balance would make the last till to sync win.
type PosCustomer struct {
Id string `json:"id"`
Mobile string `json:"mobile"`
Name string `json:"name"`
Email string `json:"email"`
Gender string `json:"gender"`
Dateofbirth string `json:"date_of_birth"`
Registeredat string `json:"registered_at"`
Registeredbyterminal string `json:"registered_by_terminal"`
}
type PosCustomerBatch struct {
Schema int `json:"schema"`
Batchid string `json:"batch_id"`
Storeid string `json:"store_id"`
Terminalid string `json:"terminal_id"`
Sentat string `json:"sent_at"`
Customers []PosCustomer `json:"customers"`
}
// PosAck is the only thing that retires a bill on the terminal.
//
// The rule the whole design rests on: a till marks a record synced if and only
// if its id appears in Accepted. Silence is not acceptance — an empty ack, a
// dropped connection or a 200 with no body all leave the record pending and it
// is sent again.
//
// Naming an id in Rejected is a decision, not a fault: the terminal stops
// retrying that record and waits for a person. Use it for "this bill is
// malformed", never for "the database is having a bad minute" — for the latter,
// do not ack at all and let the till back off and retry.
type PosAck struct {
Batchid string `json:"batch_id"`
Accepted []string `json:"accepted"`
Rejected map[string]string `json:"rejected,omitempty"`
}
// NewPosAck returns an ack with non-nil members, so it serialises as `[]` and
// `{}` rather than `null`. A terminal reading null for accepted would treat the
// whole batch as unconfirmed.
func NewPosAck(batchID string) *PosAck {
return &PosAck{
Batchid: batchID,
Accepted: make([]string, 0),
Rejected: make(map[string]string),
}
}
func (a *PosAck) Accept(id string) {
a.Accepted = append(a.Accepted, id)
}
func (a *PosAck) Reject(id, reason string) {
a.Rejected[id] = reason
}
// PosCatalogueProduct is one product as the till stores it.
type PosCatalogueProduct struct {
Id string `json:"id"`
Name string `json:"name"`
Barcode string `json:"barcode"`
Sku string `json:"sku"`
Category string `json:"category"`
Price float64 `json:"price"`
Mrp float64 `json:"mrp,omitempty"`
Stock float64 `json:"stock"`
Unit string `json:"unit"`
Gstrate float64 `json:"gst_rate"`
Hsncode string `json:"hsn_code,omitempty"`
Brand string `json:"brand,omitempty"`
Isactive bool `json:"is_active"`
}
// PosCatalogueCustomer is a shopper travelling *down* to a terminal.
//
// The mirror of PosCustomer, and the difference is the point: the uplink
// carries no loyalty figures because a till's local balance is only its own
// view, while the downlink carries them because the back office has seen every
// counter and is the only thing that can total them.
type PosCatalogueCustomer struct {
Id string `json:"id"`
Name string `json:"name"`
Mobile string `json:"mobile"`
Email string `json:"email,omitempty"`
Gender string `json:"gender,omitempty"`
Dateofbirth string `json:"date_of_birth,omitempty"`
Loyaltypoints int `json:"loyalty_points"`
Lifetimespend float64 `json:"lifetime_spend"`
Visitcount int `json:"visit_count"`
Createdat string `json:"created_at,omitempty"`
Lastvisitat string `json:"last_visit_at,omitempty"`
}
// PosCatalogueResponse answers a terminal's catalogue pull.
//
// Isdelta is load-bearing. A response marked false is treated as a full
// snapshot and the terminal withdraws every product it does not mention — so
// answering a change set with false empties the shelf.
type PosCatalogueResponse struct {
Revision string `json:"revision"`
Isdelta bool `json:"is_delta"`
Hasmore bool `json:"has_more"`
Products []PosCatalogueProduct `json:"products"`
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
}
// PosRoleCanManageStaff reports whether a role may create and edit till users.
//
// Supervisors, plus the back office's own admin and manager roles — somebody
// who can already administer the shop from a browser is not made less
// privileged by standing at the counter.
//
// A cashier is never included, and neither is roleid 0. Zero 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 PosRoleCanManageStaff(roleID int) bool {
switch roleID {
case PosRoleSupervisor, 1, 2, 3, 4, 5, 6:
return true
}
return false
}
// 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"`
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"`
}