Files
backend_fiesta/services/posService.go
Suriya c4dfcd5387 Serve a shop's own staff to the till, so the built-in PINs can retire
The terminal shipped with three names and three PINs compiled into it. Same
three on every install, readable by anyone with the APK, and permanent —
nothing anywhere could replace them.

`/pos/staff` answers with the people the back office says may ring a bill at an
outlet, and the same list rides down with the session so a till is ready to
trade the moment it signs in. The terminal writes them over its own and
deactivates whatever it had, which is what actually kills the seeded logins.

Two sources are unioned because the schema has two and neither is complete.
`tenantstaffs` is the table built for this and holds 12 rows on the entire
platform; `app_users.locationid` is where staff actually ended up. Either alone
returns nothing for almost every shop.

The endpoint takes no location parameter. The answer carries PINs, so the
outlet comes from the caller's token and a request without one is refused
whatever POS_AUTH_REQUIRED says — a till must not be able to ask who works at
the shop next door.

Rows with no PIN are dropped rather than sent: a name on screen nobody can sign
in as reads as a broken terminal rather than as an unfinished setup. Duplicate
PINs are dropped too, keeping the first — live data has 1234 on eleven accounts
and 1111 on nine, and two people sharing one would make the till attribute a
bill to whichever row it checked first.

The PIN travels in the clear over TLS, deliberately. Four digits are
brute-forceable in microseconds however they are wrapped, so hashing here would
buy the appearance of strength and not the substance — while costing something
real, since the terminal salts every PIN with its own salt before storing it
and could never verify a hash computed here. A PIN is shift attribution, not a
security boundary; the boundary is the session token.

Verified against live data, and it says the fallback still matters: outlet 1135
— the one the POS actually uses — has zero staff, and the only staff row found
anywhere is a delivery rider on PIN 1111.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:01:49 +05:30

123 lines
4.2 KiB
Go

package services
import (
"context"
"time"
"nearle/models"
"nearle/repositories"
"nearle/utils"
)
type PosService 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)
// RecordHealth stores one heartbeat. Never acknowledged back to the till:
// presence is a fire-and-forget signal, and a terminal that stopped selling
// because its heartbeat failed would be a worse outcome than a blank board.
RecordHealth(ctx context.Context, health models.PosHealth) error
TerminalHealth(ctx context.Context, terminalID string) (map[string]string, error)
LocationHealth(ctx context.Context, locationID string) ([]map[string]string, error)
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
SaleDetail(locationID int, reference string) (*models.PosOrders, error)
SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error)
// Login authenticates a person against the same account store the web
// console uses and mints the session a till carries for the trading day.
Login(req models.PosLoginRequest) (*models.PosSession, error)
// LocationAllowed is the authorisation check every other POS call rests on:
// does the tenant in the caller's token actually own this outlet.
LocationAllowed(tenantID, locationID int) (bool, error)
// Staff lists who may ring a bill at an outlet. Sent with the session and
// available on its own, so a shop that hires someone mid-shift can pull them
// down without signing the terminal out.
Staff(tenantID, locationID int) ([]models.PosStaffMember, error)
}
type posService struct {
repo repositories.PosRepository
presence repositories.PosPresenceRepository
}
func NewPosService(repo repositories.PosRepository, presence repositories.PosPresenceRepository) PosService {
return &posService{repo: repo, presence: presence}
}
func (s *posService) RecordHealth(ctx context.Context, health models.PosHealth) error {
return s.presence.Record(ctx, health)
}
func (s *posService) TerminalHealth(ctx context.Context, terminalID string) (map[string]string, error) {
return s.presence.Terminal(ctx, terminalID)
}
func (s *posService) LocationHealth(ctx context.Context, locationID string) ([]map[string]string, error) {
return s.presence.Location(ctx, locationID)
}
func (s *posService) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) {
return s.repo.IngestOrders(batch)
}
func (s *posService) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) {
return s.repo.IngestCustomers(batch)
}
func (s *posService) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
return s.repo.Catalogue(storeID, since, page, pageSize)
}
func (s *posService) Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) {
return s.repo.Sales(f)
}
func (s *posService) SaleDetail(locationID int, reference string) (*models.PosOrders, error) {
return s.repo.SaleDetail(locationID, reference)
}
func (s *posService) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) {
return s.repo.SalesSummary(f)
}
// Login authenticates a terminal's operator and issues its session.
//
// The token is minted here rather than in the repository so that the signing
// key stays out of the layer that talks to the database, and so a future change
// of token format touches one function.
func (s *posService) Login(req models.PosLoginRequest) (*models.PosSession, error) {
session, err := s.repo.PosLogin(req)
if err != nil {
return nil, err
}
token, expires, err := utils.MintPosToken(utils.PosClaims{
Userid: session.Userid,
Tenantid: session.Tenantid,
Locationid: session.Locationid,
Roleid: session.Roleid,
Terminalid: req.Terminalid,
}, time.Now())
if err != nil {
return nil, err
}
session.Token = token
session.Expiresat = expires.UTC().Format(time.RFC3339)
return session, nil
}
func (s *posService) LocationAllowed(tenantID, locationID int) (bool, error) {
return s.repo.PosLocationAllowed(tenantID, locationID)
}
func (s *posService) Staff(tenantID, locationID int) ([]models.PosStaffMember, error) {
return s.repo.PosStaff(tenantID, locationID)
}