A shop had no way to add the people who work in it. The terminal fell back to
three names and three PINs compiled into the app — the same three on every
install — because there was nothing for it to fall back *from*.
Two roles now exist in `app_roles`: Supervisor (7) runs the terminal and creates
staff, Cashier (8) bills. Fixed ids, written by hand, because that table has no
sequence and every id in it was assigned the same way. configid is left NULL
rather than duplicated per portal: a till is a till whichever portal a tenant
uses, and Admin already appears twice in that table for exactly that reason.
`/pos/users` is CRUD over them, and `/pos/login/pin` signs a cashier on at a
terminal a supervisor has already opened.
The rule every one of these follows: **tenant and outlet come from the caller's
token, never from the request.** There is no location field on the create body
to get wrong. A supervisor at Selvapuram cannot create staff at R mart, for the
same reason a till cannot bill into another shop's books — it is the same
inversion applied to people instead of sales.
PIN sign-in is deliberately behind the guard. Four digits is ten thousand
guesses, which is no barrier to an anonymous caller; requiring a session means a
real password opened the terminal first and the guesses are confined to one
outlet's own staff. The session it mints is fresh rather than derived, so a
cashier taking over from a supervisor drops their permissions instead of
inheriting them.
Three things the schema forced:
- A PIN cannot start with zero. `app_users.pin` is a bigint, so "0451" stores as
451 and reads back as three digits — a cashier would type four and be refused
for ever. Live data already holds one such account. Rendering refuses to show
a PIN it cannot represent, rather than showing a short one nobody can type.
- `app_users` has no sequence either, so the next id is read and written inside
one transaction behind an advisory lock. Two supervisors creating staff at the
same moment would otherwise compute the same id and one insert would lose.
- 1234, 1111 and friends are refused outright. Live data has 1234 on eleven
accounts and 1111 on nine.
Proven against outlet 1135, which had zero staff and was the reason the built-in
PINs were still load-bearing:
created 9188 Store Supervisor Supervisor can_manage_staff=true
created 9189 Counter Cashier Cashier can_manage_staff=false
/pos/staff now returns 2 an unknown PIN is refused
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
6.4 KiB
Go
171 lines
6.4 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)
|
|
|
|
// Till staff management, all scoped to the caller's own outlet.
|
|
CreateUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error)
|
|
UpdateUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error)
|
|
ListUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error)
|
|
DeactivateUser(tenantID, locationID, userID int) error
|
|
|
|
// LoginWithPin signs a person in at a terminal that is already open. Never
|
|
// reachable anonymously — four digits is not a barrier on its own.
|
|
LoginWithPin(tenantID, locationID int, pin string) (*models.PosSession, 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
|
|
}
|
|
|
|
return s.mint(session, req.Terminalid)
|
|
}
|
|
|
|
// mint signs a resolved session.
|
|
//
|
|
// Kept apart from the credential checks so the signing key stays out of the
|
|
// layer that talks to the database, and so a change of token format touches one
|
|
// function rather than every way in.
|
|
func (s *posService) mint(session *models.PosSession, terminalID string) (*models.PosSession, error) {
|
|
token, expires, err := utils.MintPosToken(utils.PosClaims{
|
|
Userid: session.Userid,
|
|
Tenantid: session.Tenantid,
|
|
Locationid: session.Locationid,
|
|
Roleid: session.Roleid,
|
|
Configid: session.Configid,
|
|
Terminalid: 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)
|
|
}
|
|
|
|
func (s *posService) CreateUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error) {
|
|
return s.repo.CreatePosUser(tenantID, locationID, configID, req)
|
|
}
|
|
|
|
func (s *posService) UpdateUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error) {
|
|
return s.repo.UpdatePosUser(tenantID, locationID, req)
|
|
}
|
|
|
|
func (s *posService) ListUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error) {
|
|
return s.repo.ListPosUsers(tenantID, locationID, includeInactive)
|
|
}
|
|
|
|
func (s *posService) DeactivateUser(tenantID, locationID, userID int) error {
|
|
return s.repo.DeactivatePosUser(tenantID, locationID, userID)
|
|
}
|
|
|
|
// LoginWithPin mints a fresh session for whoever the PIN belongs to.
|
|
//
|
|
// A new token rather than a reused one, because the token carries the role and
|
|
// a cashier taking over from a supervisor must not inherit their permissions.
|
|
func (s *posService) LoginWithPin(tenantID, locationID int, pin string) (*models.PosSession, error) {
|
|
session, err := s.repo.PosLoginByPin(tenantID, locationID, pin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.mint(session, "")
|
|
}
|