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