Files
backend_fiesta/models/pos.go

520 lines
21 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.
//
// A mobile number and a four-digit PIN. That is what a person standing at a
// counter can actually type between customers, and it is the pair the console
// issues them — anything longer gets written on the side of the terminal, which
// is worse than a short credential.
//
// 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 {
// The mobile number this person signs in with. Sent in whatever form they
// typed it — "+91 98765 43210", "098765-43210", "9876543210" — and reduced
// to ten digits by the server before it is matched.
Contactno string `json:"contactno"`
// A four-digit PIN, and the credential this endpoint now checks.
//
// Four digits is ten thousand guesses, which would be no barrier at all on
// its own — it is a barrier here only because it is checked against one
// mobile number, and a mobile number is unique among a tenant's till
// accounts. Rate limiting at the edge is what stands between that and a
// patient attacker; this endpoint cannot supply it.
Pin string `json:"pin"`
// A username and password, the way in before PINs.
//
// Kept working, not deprecated in place, because every till account on the
// platform predates the mobile number it now signs in with. Removing this
// before the back office has filled those in would close every shop on the
// same morning. See docs/POS_PHONE_PIN_LOGIN_HANDOVER.md §5.
Authname string `json:"authname,omitempty"`
Password string `json:"password,omitempty"`
Configid int `json:"configid"`
Locationid int `json:"location_id"`
// 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 used to travel down with this list, on the reasoning that a PIN was
// *shift attribution* rather than a security boundary: the token decided which
// books a till could reach, and the PIN only decided which of the people
// already inside a shop got credited with a sale.
//
// That reasoning ended when the PIN became half of the sign-in. A list of PINs
// is now a list of working credentials for the outlet — including the
// supervisor's, which carries `can_manage_staff` — so a cashier handed this
// array could sign back in as their own manager. Hence `json:"-"`: the field is
// still read from the database, because the query needs it to drop two people
// who share a PIN, but it cannot reach the wire from here.
//
// Switching operator at an open terminal goes through `POST /pos/login/pin`,
// which checks the PIN against the outlet the caller's token already names.
type PosStaffMember struct {
Userid int `json:"user_id"`
Fullname string `json:"full_name"`
Role string `json:"role"`
Pin string `json:"-"`
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"`
// The shift this person works, resolved for display. Zero / empty when the
// account has none, which is every account created before shifts existed.
Shiftid int `json:"shift_id,omitempty"`
Shiftname string `json:"shift_name,omitempty"`
Shiftstart string `json:"shift_start,omitempty"`
Shiftend string `json:"shift_end,omitempty"`
// The password, returned only in the answer to a creation or a reset and
// never by a listing. An admin who loses it reissues rather than looks it
// up — the right shape even while the column behind it is plaintext.
Password string `json:"password,omitempty"`
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"`
// The mobile number this person signs in with.
//
// Normalised to ten digits before it is stored, because the till matches on
// it exactly and "+91 98765 43210" typed back as "9876543210" would not
// find the row. Unique among a tenant's till accounts.
Contactno string `json:"contactno"`
// Which shift this person works — a staffshifts.staffshiftid, stored on
// app_users.shiftid. Zero leaves it unset.
//
// Informational. Nothing refuses a bill rung outside it; the terminal shows
// it so a counter knows who is due.
Shiftid int `json:"shift_id"`
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"`
}