An admin sets a shop up from a browser; a supervisor adds a cashier at the counter. Both had to be possible, and only the second one was. So the console gets createposuser / updateposuser / getposusers / deleteposuser, under both /v1/web/tenants and /v1/mob/tenants — calling the same service methods `/pos/users` calls. Not a parallel implementation: a supervisor created from a browser is the same row, with the same PIN rules, the same duplicate check and the same identity-column allocation, as one created at a till. Two paths writing one table is precisely how the two stop matching, and this codebase already had that happen once. configid is inferred rather than asked for. It is a number nobody looks up, it varies per tenant — 1087's accounts are spread across 1, 6 and 15 — and getting it wrong creates somebody who cannot sign into the portal their colleagues use and is invisible to half the platform's queries. /posroles is served rather than left to the console to hardcode. A console that knew supervisor was 7 would be wrong the day that changed and would have no way to find out. The outlet is the real difference between the two doors. A terminal proves it with a signed token; the console asserts it, and is checked against the tenant before anything is written. That is weaker, and it is worth being plain about: these mint till credentials on an unauthenticated request, exactly like every other route in the /v1/web and /v1/mob groups, because there is no auth middleware on the web API at all. Documented as the weakest point in the design and flagged to move behind a session guard once the console can hold one. The terminal routes are untouched by it. Proven in a rolled-back transaction against live data: the console creates a supervisor at 1135, that supervisor signs in by PIN with can_manage_staff true, the till's /pos/staff sees them alongside the two created at the counter, and 0451, 1234 and a duplicate PIN are each refused with the same message the terminal gives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
6.6 KiB
Go
179 lines
6.6 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)
|
|
|
|
// ConfigidFor infers which portal a tenant's people belong to, so the console
|
|
// is never asked for a number nobody can look up.
|
|
ConfigidFor(tenantID int) int
|
|
}
|
|
|
|
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, "")
|
|
}
|
|
|
|
func (s *posService) ConfigidFor(tenantID int) int {
|
|
return s.repo.PosConfigidFor(tenantID)
|
|
}
|