package repositories import ( "encoding/json" "errors" "fmt" "strconv" "strings" "time" "nearle/models" "gorm.io/gorm" ) // Ingestion for the Nearle POS terminal. // // Bills arrive here already rung up and paid for — the till is the system of // record until we say otherwise, and it holds its own copy for a week on the // strength of our acknowledgement. Two consequences shape everything below. // // **A duplicate is a success.** Delivery is at-least-once: a lost ack makes a // terminal re-send bills that are already banked. Reporting those as failures // would strand a day of takings on the till for ever. So a bill we already hold // is accepted, silently, without touching stock again. // // **Acknowledge only after the commit.** A bill named in the ack is one the // terminal is entitled to delete. Saying so before the transaction lands would // trade a real sale for a queue position. // // The commit itself is deliberately not reimplemented here. Each bill runs // through createOrderTx, the same path an app order and a spreadsheet import // take, so stock deduction, the per-product row locks that prevent overselling, // the ledger entries and sequence allocation stay shared rather than forked. type PosRepository interface { IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) } type posRepository struct { db *gorm.DB // Held rather than embedded so the order machinery is reached explicitly. orders *orderRepository } func NewPosRepository(db *gorm.DB) PosRepository { return &posRepository{db: db, orders: &orderRepository{db: db}} } // resolvePosStore turns the terminal's store_id into an authorised outlet. // // The till sends a location and nothing else. The tenant is looked up from it // here and never accepted from the wire: a terminal that could name its own // tenant could post sales into somebody else's books. func (r *posRepository) resolvePosStore(storeID string) (*offlineLocationContext, error) { locationID, err := strconv.Atoi(strings.TrimSpace(storeID)) if err != nil || locationID <= 0 { return nil, fmt.Errorf("store_id %q is not a location id; configure the terminal's Store ID with the numeric locationid", storeID) } var tenantID int err = r.db.Raw( `SELECT COALESCE(MIN(tenantid), 0) FROM tenantlocations WHERE locationid = ?`, locationID, ).Scan(&tenantID).Error if err != nil { return nil, err } if tenantID <= 0 { return nil, fmt.Errorf("no outlet is registered with locationid %d", locationID) } return r.orders.resolveOfflineLocationContext(tenantID, locationID) } // IngestOrders commits a batch of counter bills and reports what landed. // // A failure to resolve the outlet at all returns an error rather than an ack, // so the terminal treats the outcome as unknown and retries. A failure on one // bill is reported against that bill alone and the rest still commit. func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) { ack := models.NewPosAck(batch.Batchid) if len(batch.Orders) == 0 { return ack, nil } ctx, err := r.resolvePosStore(batch.Storeid) if err != nil { return nil, err } products, err := r.orders.loadOfflineProducts(ctx.Tenantid, ctx.Locationid) if err != nil { return nil, err } if len(products) == 0 { return nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname) } for _, order := range batch.Orders { if strings.TrimSpace(order.Id) == "" { // Nothing to key on, so it can never be deduplicated. Refusing it // is safer than admitting a bill that would double on every retry. ack.Reject("", "order is missing its id") continue } if reason := r.importPosOrder(ctx, products, batch.Batchid, order); reason != "" { ack.Reject(order.Id, reason) continue } ack.Accept(order.Id) } return ack, nil } // importPosOrder commits one bill, or leaves nothing behind. // // Returns an empty string on success — including the case where the bill was // already held, which is a success from the terminal's point of view. // // The bill lands in pos_orders at full fidelity, and the stock it consumed goes // through the same productstocks ledger an app order uses. Those two facts pull // in opposite directions and both matter: the bill is its own kind of document // and deserves its own table, but stock is one number per shelf and must not be // tracked twice. func (r *posRepository) importPosOrder( ctx *offlineLocationContext, products map[int]offlineProduct, batchID string, order models.PosOrder, ) string { if len(order.Items) == 0 { return "bill has no items" } saleDate, err := parsePosSaleDate(order.Createdat) if err != nil { return err.Error() } // The till has already apportioned bill-level discounts across its lines to // get the tax right, but it sends each line at its own pre-apportionment // value. Left alone, the item rows would sum to the subtotal while the // header carried the total, and every report that adds up lines would // disagree with the one that reads the header. // // So the lines are scaled onto what was actually collected. The till's own // figures stay authoritative for the bill as a whole; this only decides how // that whole is attributed across the lines inside it. netAmount := order.Total - order.Roundoff lineSum := 0.0 taxSum := 0.0 for _, item := range order.Items { lineSum += item.Linetotal taxSum += item.Tax } amountFactor := 1.0 if lineSum > 0 && netAmount > 0 { amountFactor = netAmount / lineSum } taxFactor := 1.0 if taxSum > 0 && order.Tax > 0 { taxFactor = order.Tax / taxSum } items := make([]models.PosOrderItems, 0, len(order.Items)) lines := make([]stockLine, 0, len(order.Items)) var taxTotal float64 for _, raw := range order.Items { productID, err := strconv.Atoi(strings.TrimSpace(raw.Productid)) if err != nil || productID <= 0 { return fmt.Sprintf("line '%s' has product_id %q, which is not a catalogue id", raw.Name, raw.Productid) } // Membership of this map is the ownership check. A product absent from // it is either another tenant's or not stocked here, and either way the // bill is refused rather than posted against a catalogue it has no // claim on. product, ok := products[productID] if !ok { return fmt.Sprintf("product %d is not stocked at %s", productID, ctx.Locationname) } if raw.Quantity <= 0 { return fmt.Sprintf("product '%s' has a quantity of %g; it must be greater than zero", product.Productname, raw.Quantity) } landing := raw.Linetotal * amountFactor taxAmount := raw.Tax * taxFactor gross := raw.Unitprice * raw.Quantity discount := gross - landing if discount < 0 { discount = 0 } taxTotal += taxAmount items = append(items, models.PosOrderItems{ Tenantid: ctx.Tenantid, Locationid: ctx.Locationid, Productid: productID, Productname: product.Productname, Barcode: raw.Barcode, Unitname: product.Productunit, Quantity: raw.Quantity, Unitprice: raw.Unitprice, Discountamount: discount, Gstrate: raw.Gstrate, Taxamount: taxAmount, Linetotal: landing, }) lines = append(lines, stockLine{ Productid: productID, Locationid: ctx.Locationid, Productname: product.Productname, Quantity: raw.Quantity, }) } paymentMode := "cash" if len(order.Payments) > 0 { // The largest tender names the bill. A split paid mostly by card with // ten rupees of change in cash is a card sale in every report anyone // actually reads — the full split is kept in Paymentsjson regardless. largest := order.Payments[0] for _, p := range order.Payments[1:] { if p.Amount > largest.Amount { largest = p } } if m := strings.ToLower(strings.TrimSpace(largest.Method)); m != "" { paymentMode = m } } tx := r.db.Begin() if tx.Error != nil { return fmt.Sprintf("could not start a transaction: %v", tx.Error) } // Held for the life of the transaction, so a redelivery arriving at the // same moment waits here and then sees the committed row rather than racing // past the check below and banking the sale twice. The unique index on // terminalorderid would catch it either way; this turns a constraint // violation into an orderly "already held". lockKey := "possale:" + strings.ToUpper(strings.TrimSpace(order.Id)) if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext(?))`, lockKey).Error; err != nil { tx.Rollback() return fmt.Sprintf("could not lock bill %s: %v", order.Invoicenumber, err) } var already int err = tx.Raw( `SELECT COALESCE(COUNT(*), 0) FROM pos_orders WHERE terminalorderid = ?`, strings.TrimSpace(order.Id), ).Scan(&already).Error if err != nil { tx.Rollback() return fmt.Sprintf("could not check whether bill %s was already held: %v", order.Invoicenumber, err) } if already > 0 { // Already banked. Accepted, not rejected — this is the ordinary result // of a lost ack, and calling it a failure would leave the till holding // a bill we have had all along. Stock is deliberately untouched. tx.Rollback() return "" } // Locks first, then availability, then the writes — the same order an app // order takes, and the reason two tills selling the last unit cannot both // succeed. if err := lockStockRows(tx, ctx.Tenantid, lines); err != nil { tx.Rollback() return err.Error() } if err := assertStockAvailable(tx, ctx.Tenantid, lines, func(l stockLine) int { return roundStockQty(l.Quantity) }); err != nil { tx.Rollback() return err.Error() } customerID, err := r.orders.resolveOfflineCustomer(tx, ctx, posCustomerName(order), posCustomerMobile(order)) if err != nil { tx.Rollback() return fmt.Sprintf("could not resolve the customer: %v", err) } bill := models.PosOrders{ Terminalorderid: strings.TrimSpace(order.Id), Invoicenumber: order.Invoicenumber, Tenantid: ctx.Tenantid, Locationid: ctx.Locationid, Terminalid: order.Terminalid, Cashiername: order.Cashier, Customerid: customerID, Customermobile: posCustomerMobile(order), Customername: posCustomerName(order), Billedat: saleDate, // The day the sale was rung, not the day it arrived. A till that was // offline overnight uploads yesterday's bills this morning, and every // daily figure has to follow the sale rather than the upload. Businessdate: saleDate.Format("2006-01-02"), Subtotal: order.Subtotal, Discount: order.Discount, Taxamount: taxTotal, Roundoff: order.Roundoff, Total: order.Total, Pointsearned: order.Pointsearned, Pointsredeemed: order.Pointsredeemed, Itemcount: len(items), Paymentmode: paymentMode, Paymentsjson: posJSON(order.Payments), Promosjson: posJSON(order.Promos), // Every jsonb column must carry valid JSON. Left at Go's zero value an // empty string reaches Postgres and the whole insert fails with // "invalid input syntax for type json" — taking the bill down with it. Taxbreakdownjson: posJSON(order.Taxbreakdown), Batchid: batchID, Receivedat: time.Now(), } if err := tx.Create(&bill).Error; err != nil { tx.Rollback() return fmt.Sprintf("could not write bill %s: %v", order.Invoicenumber, err) } for i := range items { items[i].Posorderid = bill.Posorderid if err := tx.Create(&items[i]).Error; err != nil { tx.Rollback() return fmt.Sprintf("could not write a line of bill %s: %v", order.Invoicenumber, err) } // The same ledger an app order writes to. A second stock ledger for // counter sales would mean the catalogue pull sends a till figures that // ignore the till's own trading. if err := recordStockOut(tx, ctx.Tenantid, lines[i], roundStockQty(lines[i].Quantity)); err != nil { tx.Rollback() return fmt.Sprintf("could not deduct stock for bill %s: %v", order.Invoicenumber, err) } } if err := tx.Commit().Error; err != nil { return fmt.Sprintf("could not commit bill %s: %v", order.Invoicenumber, err) } return "" } // posJSON encodes a payload column. // // Falls back to a JSON null rather than failing the bill: these columns exist // to be read back later, and losing one is not a reason to refuse a sale the // shopper has already paid for. func posJSON(v any) string { body, err := json.Marshal(v) if err != nil || len(body) == 0 { return "null" } return string(body) } func posCustomerName(order models.PosOrder) string { if order.Customer == nil { return "" } return order.Customer.Name } func posCustomerMobile(order models.PosOrder) string { if order.Customer == nil { return "" } return order.Customer.Mobile } // parsePosSaleDate reads the till's timestamp. // // The terminal sends ISO-8601. A blank one falls back to now; an unparseable // one is refused, because importing a sale under the wrong date corrupts every // daily revenue figure that reads it. func parsePosSaleDate(raw string) (time.Time, error) { raw = strings.TrimSpace(raw) if raw == "" { return time.Now(), nil } for _, layout := range []string{ time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.999999", "2006-01-02 15:04:05", } { if t, err := time.Parse(layout, raw); err == nil { return t, nil } } return time.Time{}, fmt.Errorf("unrecognised created_at %q", raw) } // IngestCustomers records shoppers registered at a till. // // Insert-if-absent, never an update. A registration is replayed freely, and a // profile corrected at head office must not be reverted by a terminal replaying // what it captured months ago. func (r *posRepository) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) { ack := models.NewPosAck(batch.Batchid) if len(batch.Customers) == 0 { return ack, nil } ctx, err := r.resolvePosStore(batch.Storeid) if err != nil { return nil, err } for _, customer := range batch.Customers { mobile := strings.TrimSpace(customer.Mobile) if strings.TrimSpace(customer.Id) == "" || mobile == "" { ack.Reject(customer.Id, "registration is missing its id or mobile number") continue } if err := r.upsertPosCustomer(ctx, customer, mobile); err != nil { ack.Reject(customer.Id, err.Error()) continue } ack.Accept(customer.Id) } return ack, nil } // upsertPosCustomer attaches the shopper to this outlet's app location. // // Matched on contactno, which is what the rest of the system already keys a // shopper on, so a shopper registered at a till and one who installed the app // end up as one row rather than two. func (r *posRepository) upsertPosCustomer( ctx *offlineLocationContext, customer models.PosCustomer, mobile string, ) error { name := strings.TrimSpace(customer.Name) if name == "" { name = "Counter Customer" } var existing int err := r.db.Raw( `SELECT COALESCE(MIN(customerid), 0) FROM customers WHERE contactno = ? AND applocationid = ?`, mobile, ctx.Applocationid, ).Scan(&existing).Error if err != nil { return err } if existing > 0 { // Already known. Accepted without a write — the terminal's copy is not // newer than ours in any way we can establish. return nil } // status 0 mirrors every other customer row in production, including ones // actively placing orders. A different value here would make a shopper // registered at the counter behave unlike all the others. var created int err = r.db.Raw(` INSERT INTO customers (configid, firstname, lastname, contactno, email, gender, dob, applocationid, locationid, status, created, updated) VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, 0, NOW(), NOW()) RETURNING customerid`, ctx.Configid, name, mobile, strings.TrimSpace(customer.Email), strings.TrimSpace(customer.Gender), strings.TrimSpace(customer.Dateofbirth), ctx.Applocationid, ctx.Locationid, ).Scan(&created).Error if err != nil { return err } if created <= 0 { return errors.New("failed to create the customer row") } return nil } // Catalogue answers a terminal's morning pull. // // Always a full snapshot today, and it says so. The terminal withdraws every // product a snapshot omits, so answering a change set with is_delta false would // empty the shelf — declaring false here is only safe because this really does // return everything stocked at the outlet. func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) { ctx, err := r.resolvePosStore(storeID) if err != nil { return nil, err } if pageSize <= 0 || pageSize > 1000 { pageSize = 500 } if page < 0 { page = 0 } type row struct { Productid int Productname string Productsku string Categoryname string Productunit string Productbrand string Price float64 Retailprice float64 Taxpercent float64 Stock float64 Status string } rows := make([]row, 0) err = r.db.Raw(` SELECT a.productid, COALESCE(a.productname, '') AS productname, COALESCE(a.productsku, '') AS productsku, COALESCE(c.categoryname, '') AS categoryname, COALESCE(a.productunit, '') AS productunit, COALESCE(a.productbrand, '') AS productbrand, CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price, COALESCE(a.retailprice, 0) AS retailprice, COALESCE(a.taxpercent, 0) AS taxpercent, COALESCE(( SELECT SUM(CASE WHEN LOWER(s.stocktype) = 'in' THEN s.quantity ELSE 0 END) - SUM(CASE WHEN LOWER(s.stocktype) = 'out' THEN s.quantity ELSE 0 END) FROM productstocks s WHERE s.productid = a.productid AND s.tenantid = a.tenantid AND s.locationid = b.locationid ), 0) AS stock, COALESCE(b.status, '') AS status FROM products a INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid LEFT JOIN productcategories c ON a.categoryid = c.categoryid WHERE a.tenantid = ? AND b.locationid = ? ORDER BY a.productid LIMIT ? OFFSET ?`, ctx.Tenantid, ctx.Locationid, pageSize+1, page*pageSize, ).Scan(&rows).Error if err != nil { return nil, err } // One row beyond the page was requested purely to answer has_more without a // second count query. hasMore := len(rows) > pageSize if hasMore { rows = rows[:pageSize] } products := make([]models.PosCatalogueProduct, 0, len(rows)) for _, p := range rows { // A productid of zero is bad data, not a product — live data has at // least one, almost certainly an insert that never got a sequence // value. Sending it would put a row on the till that can never be // billed, because the ingest refuses any line whose id is not positive. if p.Productid <= 0 { continue } mrp := p.Retailprice if mrp <= p.Price { mrp = 0 } // Indian GST is 0/5/12/18/28, but the column holds 3, 4, 6, 7, 9, 10, // 15 and even -1 across live data. A negative rate would put negative // tax on a bill and a negative figure in a slab on a filed return, so // it is floored here rather than trusted. gstRate := p.Taxpercent / 100 if gstRate < 0 { gstRate = 0 } // Withdrawn from sale when there is no selling price. Neither // productlocations.price nor retailprice is set on much of the estate — // only productcost is — and a till that can ring an item up at ₹0 is // worse than one that cannot ring it up at all. Pricing the product // makes it sellable; nothing here needs changing. sellable := p.Price > 0 && !strings.EqualFold(strings.TrimSpace(p.Status), "outofstock") products = append(products, models.PosCatalogueProduct{ Id: strconv.Itoa(p.Productid), Name: p.Productname, Barcode: posBarcode(p.Productid, p.Productsku), Sku: p.Productsku, Category: posCategory(p.Categoryname), Price: p.Price, Mrp: mrp, Stock: p.Stock, Unit: posUnit(p.Productunit), Gstrate: gstRate, Brand: p.Productbrand, Isactive: sellable, }) } return &models.PosCatalogueResponse{ // A revision the terminal stores and sends back on its next pull. Tied // to the outlet and the moment, so a shop that has pulled today can be // told it is already current. Revision: fmt.Sprintf("loc%d-%s", ctx.Locationid, time.Now().UTC().Format("20060102T150405")), Isdelta: false, Hasmore: hasMore, Products: products, Customers: make([]models.PosCatalogueCustomer, 0), Retiredids: make([]string, 0), }, nil } // posBarcode decides what the till scans this product by. // // The terminal holds a **unique** index on barcode, so whatever this returns has // to be distinct across the whole catalogue or the import fails outright. // // `products.productsku` cannot be trusted for that. Measured against live data: // 6,245 products carry only 93 distinct SKUs, and the single value "1" is used // by 5,794 of them. Mapping SKU straight to barcode would collapse most of the // catalogue onto one row. // // So a SKU is used only when it looks like a real scannable code — 8 to 14 // digits, the shape of an EAN-8, UPC-A or EAN-13 — and otherwise the product id // stands in. The id is unique by construction, which keeps the import working // today; the day real barcodes are populated, scanning starts working on its own // with no change here. // // Until then, scanning a physical barcode at the till will not find anything. // That is a data problem, not a code one. func posBarcode(productID int, sku string) string { sku = strings.TrimSpace(sku) if len(sku) >= 8 && len(sku) <= 14 { digitsOnly := true for _, r := range sku { if r < '0' || r > '9' { digitsOnly = false break } } if digitsOnly { return sku } } return strconv.Itoa(productID) } // posCategory maps a category name onto one of the terminal's fixed buckets. // // The till ships a closed enum, so anything unrecognised has to land somewhere; // grocery is the catch-all it already uses for uncategorised stock. func posCategory(name string) string { switch strings.ToLower(strings.TrimSpace(name)) { case "dairy": return "dairy" case "fruits", "fruit": return "fruits" case "vegetables", "vegetable": return "vegetables" case "beverages", "beverage", "drinks": return "beverages" case "snacks", "snack": return "snacks" case "personal care", "personalcare": return "personalCare" case "household", "home care", "homecare": return "household" default: return "grocery" } } // posUnit maps a unit of measure onto the terminal's enum, defaulting to pieces. func posUnit(unit string) string { switch strings.ToLower(strings.TrimSpace(unit)) { case "kg", "kilogram", "kilo": return "kilogram" case "g", "gram", "grams": return "gram" case "l", "litre", "liter": return "litre" case "ml", "millilitre", "milliliter": return "millilitre" case "pack", "packet": return "pack" default: return "piece" } }