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>
476 lines
19 KiB
Go
476 lines
19 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
|
|
}
|
|
|
|
// 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"`
|
|
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"`
|
|
}
|