Files
backend_fiesta/services/posService.go
Suriya cd2459dbb6 Let an admin create till staff from the console, through the same code
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>
2026-08-06 20:42:27 +05:30

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)
}