# app/modules/loyalty/services/pin_service.py """ Staff PIN service. Merchant-based PIN operations: - PINs belong to a merchant's loyalty program - Each store (location) has its own set of staff PINs - Staff can only use PINs at their assigned location Handles PIN operations including: - PIN creation and management - PIN verification with lockout (per store) - PIN security (failed attempts, lockout) """ import logging from sqlalchemy.orm import Session from app.modules.loyalty.config import config from app.modules.loyalty.exceptions import ( InvalidStaffPinException, StaffPinLockedException, StaffPinNotFoundException, ) from app.modules.loyalty.models import StaffPin from app.modules.loyalty.schemas.pin import PinCreate, PinUpdate logger = logging.getLogger(__name__) class PinService: """Service for staff PIN operations.""" # ========================================================================= # Read Operations # ========================================================================= def get_pin(self, db: Session, pin_id: int) -> StaffPin | None: """Get a staff PIN by ID.""" return db.query(StaffPin).filter(StaffPin.id == pin_id).first() def get_pin_by_staff_id( self, db: Session, program_id: int, staff_id: str, *, store_id: int | None = None, ) -> StaffPin | None: """Get a staff PIN by employee ID.""" query = db.query(StaffPin).filter( StaffPin.program_id == program_id, StaffPin.staff_id == staff_id, ) if store_id: query = query.filter(StaffPin.store_id == store_id) return query.first() def require_pin(self, db: Session, pin_id: int) -> StaffPin: """Get a PIN or raise exception if not found.""" pin = self.get_pin(db, pin_id) if not pin: raise StaffPinNotFoundException(str(pin_id)) return pin def list_pins( self, db: Session, program_id: int, *, store_id: int | None = None, is_active: bool | None = None, ) -> list[StaffPin]: """ List staff PINs for a program. Args: db: Database session program_id: Program ID store_id: Optional filter by store (location) is_active: Filter by active status Returns: List of StaffPin objects """ query = db.query(StaffPin).filter(StaffPin.program_id == program_id) if store_id is not None: query = query.filter(StaffPin.store_id == store_id) if is_active is not None: query = query.filter(StaffPin.is_active == is_active) return query.order_by(StaffPin.name).all() def list_pins_for_merchant( self, db: Session, merchant_id: int, *, store_id: int | None = None, is_active: bool | None = None, ) -> list[StaffPin]: """ List staff PINs for a merchant. Args: db: Database session merchant_id: Merchant ID store_id: Optional filter by store (location) is_active: Filter by active status Returns: List of StaffPin objects """ query = db.query(StaffPin).filter(StaffPin.merchant_id == merchant_id) if store_id is not None: query = query.filter(StaffPin.store_id == store_id) if is_active is not None: query = query.filter(StaffPin.is_active == is_active) return query.order_by(StaffPin.store_id, StaffPin.name).all() # ========================================================================= # Write Operations # ========================================================================= def create_pin( self, db: Session, program_id: int, store_id: int, data: PinCreate, ) -> StaffPin: """ Create a new staff PIN. Args: db: Database session program_id: Program ID store_id: Store ID (location where staff works) data: PIN creation data Returns: Created PIN """ from app.modules.loyalty.models import LoyaltyProgram # Get merchant_id from program program = db.query(LoyaltyProgram).filter(LoyaltyProgram.id == program_id).first() if not program: raise StaffPinNotFoundException(f"program:{program_id}") pin = StaffPin( merchant_id=program.merchant_id, program_id=program_id, store_id=store_id, name=data.name, staff_id=data.staff_id, ) pin.set_pin(data.pin) db.add(pin) db.commit() db.refresh(pin) logger.info( f"Created staff PIN {pin.id} for '{pin.name}' at store {store_id}" ) return pin def update_pin( self, db: Session, pin_id: int, data: PinUpdate, ) -> StaffPin: """ Update a staff PIN. Args: db: Database session pin_id: PIN ID data: Update data Returns: Updated PIN """ pin = self.require_pin(db, pin_id) if data.name is not None: pin.name = data.name if data.staff_id is not None: pin.staff_id = data.staff_id if data.pin is not None: pin.set_pin(data.pin) # Reset lockout when PIN is changed pin.failed_attempts = 0 pin.locked_until = None if data.is_active is not None: pin.is_active = data.is_active db.commit() db.refresh(pin) logger.info(f"Updated staff PIN {pin_id}") return pin def delete_pin(self, db: Session, pin_id: int) -> None: """Delete a staff PIN.""" pin = self.require_pin(db, pin_id) store_id = pin.store_id db.delete(pin) db.commit() logger.info(f"Deleted staff PIN {pin_id} from store {store_id}") def unlock_pin(self, db: Session, pin_id: int) -> StaffPin: """Unlock a locked staff PIN.""" pin = self.require_pin(db, pin_id) pin.unlock() db.commit() db.refresh(pin) logger.info(f"Unlocked staff PIN {pin_id}") return pin # ========================================================================= # Verification # ========================================================================= def verify_pin( self, db: Session, program_id: int, plain_pin: str, *, store_id: int | None = None, ) -> StaffPin: """ Verify a staff PIN. For merchant-wide programs, if store_id is provided, only checks PINs assigned to that store. This ensures staff can only use their PIN at their assigned location. Args: db: Database session program_id: Program ID plain_pin: Plain text PIN to verify store_id: Optional store ID to restrict PIN lookup Returns: Verified StaffPin object Raises: InvalidStaffPinException: PIN is invalid StaffPinLockedException: PIN is locked """ # Get active PINs (optionally filtered by store) pins = self.list_pins(db, program_id, store_id=store_id, is_active=True) if not pins: raise InvalidStaffPinException() # Try each PIN for pin in pins: # Check if locked if pin.is_locked: continue # Verify PIN if pin.verify_pin(plain_pin): # Success - record it pin.record_success() db.commit() logger.debug( f"PIN verified for '{pin.name}' at store {pin.store_id}" ) return pin # No match found - record failed attempt on the first unlocked PIN only # This limits blast radius to 1 lockout instead of N # Use merchant-specific settings if available, fall back to global config max_attempts = config.pin_max_failed_attempts lockout_minutes = config.pin_lockout_minutes if pins: from app.modules.loyalty.models import LoyaltyProgram program = db.query(LoyaltyProgram).filter(LoyaltyProgram.id == program_id).first() if program: from app.modules.loyalty.services.program_service import ( program_service as _ps, ) merchant_settings = _ps.get_merchant_settings(db, program.merchant_id) if merchant_settings: max_attempts = merchant_settings.staff_pin_lockout_attempts lockout_minutes = merchant_settings.staff_pin_lockout_minutes locked_pin = None remaining = None for pin in pins: if not pin.is_locked: is_now_locked = pin.record_failed_attempt( max_attempts=max_attempts, lockout_minutes=lockout_minutes, ) if is_now_locked: locked_pin = pin else: remaining = max(0, max_attempts - pin.failed_attempts) break # Only record on the first unlocked PIN db.commit() # If a PIN just got locked, raise that specific error if locked_pin: raise StaffPinLockedException(locked_pin.locked_until.isoformat()) raise InvalidStaffPinException(remaining) def find_matching_pin( self, db: Session, program_id: int, plain_pin: str, *, store_id: int | None = None, ) -> StaffPin | None: """ Find a matching PIN without recording attempts. Useful for checking PIN validity without side effects. Args: db: Database session program_id: Program ID plain_pin: Plain text PIN to check store_id: Optional store ID to restrict lookup Returns: Matching StaffPin or None """ pins = self.list_pins(db, program_id, store_id=store_id, is_active=True) for pin in pins: if not pin.is_locked and pin.verify_pin(plain_pin): return pin return None # Singleton instance pin_service = PinService()