Files
backend_fiesta/repositories/posRepository.go
Suriya 8709556704 Add read endpoints for counter sales
The ingest only ever wrote. A bill that reached pos_orders was safe and
completely unreachable — no screen in the product could show it, and the
only way to see a day's counter takings was to query the database by
hand.

Three endpoints: a paged bill list, one bill with its lines, and a
summary split the three ways somebody actually asks for — by tender for
reconciling a drawer, by day for a chart, by till for an outlet running
several counters.

locationid is required on all of them and is the authorisation boundary,
so a caller cannot page through another shop's takings by omitting a
parameter. Fetching a bill under the wrong outlet returns 404 even when
the reference is a real one.

Dates match businessdate rather than arrival, because a till that was
offline overnight uploads yesterday's bills this morning and they belong
to yesterday. The list is ordered by billedat for the same reason —
sorting by arrival would interleave a recovered backlog through today.

Unlike the ingest handlers these answer in the usual envelope: they are
read by the web app, not by a terminal, and nothing about them is bound
to the till's contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:17:03 +05:30

707 lines
23 KiB
Go

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)
// Reading counter sales back out. Without these a committed bill is
// unreachable from every screen in the product.
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
SaleDetail(locationID int, reference string) (*models.PosOrders, error)
SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, 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"
}
}