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>
460 lines
18 KiB
Go
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"`
|
|
}
|