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"` // 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"` 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"` }