# app/modules/loyalty/services/pin_service.py """ Staff PIN service. Handles PIN operations including: - PIN creation and management - PIN verification with lockout - PIN security (failed attempts, lockout) """ import logging from datetime import UTC, datetime 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, ) -> StaffPin | None: """Get a staff PIN by employee ID.""" return ( db.query(StaffPin) .filter( StaffPin.program_id == program_id, StaffPin.staff_id == staff_id, ) .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, *, is_active: bool | None = None, ) -> list[StaffPin]: """List all staff PINs for a program.""" query = db.query(StaffPin).filter(StaffPin.program_id == program_id) if is_active is not None: query = query.filter(StaffPin.is_active == is_active) return query.order_by(StaffPin.name).all() # ========================================================================= # Write Operations # ========================================================================= def create_pin( self, db: Session, program_id: int, vendor_id: int, data: PinCreate, ) -> StaffPin: """ Create a new staff PIN. Args: db: Database session program_id: Program ID vendor_id: Vendor ID data: PIN creation data Returns: Created PIN """ pin = StaffPin( program_id=program_id, vendor_id=vendor_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}' in program {program_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) program_id = pin.program_id db.delete(pin) db.commit() logger.info(f"Deleted staff PIN {pin_id} from program {program_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, ) -> StaffPin: """ Verify a staff PIN. Checks all active PINs for the program and returns the matching one. Args: db: Database session program_id: Program ID plain_pin: Plain text PIN to verify Returns: Verified StaffPin object Raises: InvalidStaffPinException: PIN is invalid StaffPinLockedException: PIN is locked """ # Get all active PINs for the program pins = self.list_pins(db, program_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}' in program {program_id}") return pin # No match found - record failed attempt on all unlocked PINs # This is a simplified approach; in production you might want to # track which PIN was attempted based on additional context locked_pin = None remaining = None for pin in pins: if not pin.is_locked: is_now_locked = pin.record_failed_attempt( max_attempts=config.pin_max_failed_attempts, lockout_minutes=config.pin_lockout_minutes, ) if is_now_locked: locked_pin = pin else: remaining = pin.remaining_attempts 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, ) -> 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 Returns: Matching StaffPin or None """ pins = self.list_pins(db, program_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()