# app/modules/loyalty/models/staff_pin.py """ Staff PIN database model. Merchant-based staff PINs: - 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 Provides fraud prevention by requiring staff to authenticate before performing stamp/points operations. Includes: - Secure PIN hashing with bcrypt - Failed attempt tracking - Automatic lockout after too many failures """ from datetime import UTC, datetime import bcrypt from sqlalchemy import ( Boolean, Column, DateTime, ForeignKey, Index, Integer, String, ) from sqlalchemy.orm import relationship from app.core.database import Base from models.database.base import TimestampMixin class StaffPin(Base, TimestampMixin): """ Staff PIN for loyalty operations. Each staff member can have their own PIN to authenticate stamp/points operations. PINs are hashed with bcrypt and include lockout protection. PINs are scoped to a specific store (location) within the merchant's loyalty program. """ __tablename__ = "staff_pins" id = Column(Integer, primary_key=True, index=True) # Merchant association merchant_id = Column( Integer, ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True, comment="Merchant that owns the loyalty program", ) # Program and store relationships program_id = Column( Integer, ForeignKey("loyalty_programs.id", ondelete="CASCADE"), nullable=False, index=True, ) store_id = Column( Integer, ForeignKey("stores.id", ondelete="CASCADE"), nullable=False, index=True, comment="Store (location) where this staff member works", ) # Staff identity name = Column( String(100), nullable=False, comment="Staff member name", ) staff_id = Column( String(50), nullable=True, index=True, comment="Optional staff ID/employee number", ) # PIN authentication pin_hash = Column( String(255), nullable=False, comment="bcrypt hash of PIN", ) # Security tracking failed_attempts = Column( Integer, default=0, nullable=False, comment="Consecutive failed PIN attempts", ) locked_until = Column( DateTime(timezone=True), nullable=True, comment="Lockout expires at this time", ) last_used_at = Column( DateTime(timezone=True), nullable=True, comment="Last successful use of PIN", ) # Status is_active = Column( Boolean, default=True, nullable=False, index=True, ) # ========================================================================= # Relationships # ========================================================================= merchant = relationship("Merchant", backref="staff_pins") program = relationship("LoyaltyProgram", back_populates="staff_pins") store = relationship("Store", backref="staff_pins") # Indexes __table_args__ = ( Index("idx_staff_pin_merchant_active", "merchant_id", "is_active"), Index("idx_staff_pin_store_active", "store_id", "is_active"), Index("idx_staff_pin_program_active", "program_id", "is_active"), ) def __repr__(self) -> str: return f"" # ========================================================================= # PIN Operations # ========================================================================= def set_pin(self, plain_pin: str) -> None: """ Hash and store a PIN. Args: plain_pin: The plain text PIN (typically 4-6 digits) """ salt = bcrypt.gensalt() self.pin_hash = bcrypt.hashpw(plain_pin.encode("utf-8"), salt).decode("utf-8") def verify_pin(self, plain_pin: str) -> bool: """ Verify a PIN against the stored hash. Args: plain_pin: The plain text PIN to verify Returns: True if PIN matches, False otherwise """ if not self.pin_hash: return False return bcrypt.checkpw(plain_pin.encode("utf-8"), self.pin_hash.encode("utf-8")) # ========================================================================= # Lockout Management # ========================================================================= @property def is_locked(self) -> bool: """Check if PIN is currently locked out.""" if not self.locked_until: return False return datetime.now(UTC) < self.locked_until def record_failed_attempt(self, max_attempts: int = 5, lockout_minutes: int = 30) -> bool: """ Record a failed PIN attempt. Args: max_attempts: Maximum failed attempts before lockout lockout_minutes: Duration of lockout in minutes Returns: True if account is now locked """ self.failed_attempts += 1 if self.failed_attempts >= max_attempts: from datetime import timedelta self.locked_until = datetime.now(UTC) + timedelta(minutes=lockout_minutes) return True return False def record_success(self) -> None: """Record a successful PIN verification.""" self.failed_attempts = 0 self.locked_until = None self.last_used_at = datetime.now(UTC) def unlock(self) -> None: """Manually unlock a PIN (admin action).""" self.failed_attempts = 0 self.locked_until = None # ========================================================================= # Properties # ========================================================================= @property def remaining_attempts(self) -> int: """Get remaining attempts before lockout (assuming max 5).""" return max(0, 5 - self.failed_attempts) @property def lockout_remaining_seconds(self) -> int | None: """Get seconds remaining in lockout, or None if not locked.""" if not self.locked_until: return None remaining = (self.locked_until - datetime.now(UTC)).total_seconds() return max(0, int(remaining))